Просмотр исходного кода

Merge pull request #612 from rod-hynes/dns-resolver

New DNS resolver
Rod Hynes 3 лет назад
Родитель
Сommit
8b4ea9a072

+ 4 - 0
.github/workflows/tests.yml

@@ -88,7 +88,9 @@ jobs:
           go test -v -race ./psiphon/common/parameters
           go test -v -race ./psiphon/common/protocol
           go test -v -race ./psiphon/common/quic
+          go test -v -race ./psiphon/common/resolver
           go test -v -race ./psiphon/common/tactics
+          go test -v -race ./psiphon/common/transforms
           go test -v -race ./psiphon/common/values
           go test -v -race ./psiphon/common/wildcard
           go test -v -race ./psiphon/transferstats
@@ -117,7 +119,9 @@ jobs:
           go test -v -covermode=count -coverprofile=parameters.coverprofile ./psiphon/common/parameters
           go test -v -covermode=count -coverprofile=protocol.coverprofile ./psiphon/common/protocol
           go test -v -covermode=count -coverprofile=quic.coverprofile ./psiphon/common/quic
+          go test -v -covermode=count -coverprofile=resolver.coverprofile ./psiphon/common/resolver
           go test -v -covermode=count -coverprofile=tactics.coverprofile ./psiphon/common/tactics
+          go test -v -covermode=count -coverprofile=transforms.coverprofile ./psiphon/common/transforms
           go test -v -covermode=count -coverprofile=values.coverprofile ./psiphon/common/values
           go test -v -covermode=count -coverprofile=wildcard.coverprofile ./psiphon/common/wildcard
           go test -v -covermode=count -coverprofile=transferstats.coverprofile ./psiphon/transferstats

+ 13 - 18
ConsoleClient/main.go

@@ -30,6 +30,7 @@ import (
 	"os"
 	"os/signal"
 	"sort"
+	"strings"
 	"sync"
 	"syscall"
 
@@ -77,7 +78,7 @@ func main() {
 		"The path at which to upload the feedback package when the \"-feedbackUpload\"\n"+
 			"flag is provided. Must be provided by Psiphon Inc.")
 
-	var tunDevice, tunBindInterface, tunPrimaryDNS, tunSecondaryDNS string
+	var tunDevice, tunBindInterface, tunDNSServers string
 	if tun.IsSupported() {
 
 		// When tunDevice is specified, a packet tunnel is run and packets are relayed between
@@ -96,8 +97,7 @@ func main() {
 
 		flag.StringVar(&tunDevice, "tunDevice", "", "run packet tunnel for specified tun device")
 		flag.StringVar(&tunBindInterface, "tunBindInterface", tun.DEFAULT_PUBLIC_INTERFACE_NAME, "bypass tun device via specified interface")
-		flag.StringVar(&tunPrimaryDNS, "tunPrimaryDNS", "8.8.8.8", "primary DNS resolver for bypass")
-		flag.StringVar(&tunSecondaryDNS, "tunSecondaryDNS", "8.8.4.4", "secondary DNS resolver for bypass")
+		flag.StringVar(&tunDNSServers, "tunDNSServers", "8.8.8.8,8.8.4.4", "Comma-delimited list of tun bypass DNS server IP addresses")
 	}
 
 	var noticeFilename string
@@ -211,7 +211,7 @@ func main() {
 
 	if tun.IsSupported() && tunDevice != "" {
 		tunDeviceFile, err := configurePacketTunnel(
-			config, tunDevice, tunBindInterface, tunPrimaryDNS, tunSecondaryDNS)
+			config, tunDevice, tunBindInterface, strings.Split(tunDNSServers, ","))
 		if err != nil {
 			psiphon.SetEmitDiagnosticNotices(true, false)
 			psiphon.NoticeError("error configuring packet tunnel: %s", err)
@@ -307,7 +307,9 @@ func main() {
 
 func configurePacketTunnel(
 	config *psiphon.Config,
-	tunDevice, tunBindInterface, tunPrimaryDNS, tunSecondaryDNS string) (*os.File, error) {
+	tunDevice string,
+	tunBindInterface string,
+	tunDNSServers []string) (*os.File, error) {
 
 	file, _, err := tun.OpenTunDevice(tunDevice)
 	if err != nil {
@@ -316,21 +318,19 @@ func configurePacketTunnel(
 
 	provider := &tunProvider{
 		bindInterface: tunBindInterface,
-		primaryDNS:    tunPrimaryDNS,
-		secondaryDNS:  tunSecondaryDNS,
+		dnsServers:    tunDNSServers,
 	}
 
 	config.PacketTunnelTunFileDescriptor = int(file.Fd())
 	config.DeviceBinder = provider
-	config.DnsServerGetter = provider
+	config.DNSServerGetter = provider
 
 	return file, nil
 }
 
 type tunProvider struct {
 	bindInterface string
-	primaryDNS    string
-	secondaryDNS  string
+	dnsServers    []string
 }
 
 // BindToDevice implements the psiphon.DeviceBinder interface.
@@ -338,14 +338,9 @@ func (p *tunProvider) BindToDevice(fileDescriptor int) (string, error) {
 	return p.bindInterface, tun.BindToDevice(fileDescriptor, p.bindInterface)
 }
 
-// GetPrimaryDnsServer implements the psiphon.DnsServerGetter interface.
-func (p *tunProvider) GetPrimaryDnsServer() string {
-	return p.primaryDNS
-}
-
-// GetSecondaryDnsServer implements the psiphon.DnsServerGetter interface.
-func (p *tunProvider) GetSecondaryDnsServer() string {
-	return p.secondaryDNS
+// GetDNSServers implements the psiphon.DNSServerGetter interface.
+func (p *tunProvider) GetDNSServers() []string {
+	return p.dnsServers
 }
 
 // Worker creates a protocol around the different run modes provided by the

+ 114 - 54
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -47,6 +47,7 @@ import java.io.PrintStream;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.net.Inet4Address;
+import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.NetworkInterface;
 import java.net.SocketException;
@@ -144,7 +145,7 @@ public class PsiphonTunnel {
     private final boolean mShouldRouteThroughTunnelAutomatically;
     private final NetworkMonitor mNetworkMonitor;
     private AtomicReference<String> mActiveNetworkType;
-    private AtomicReference<String> mActiveNetworkPrimaryDNSServer;
+    private AtomicReference<String> mActiveNetworkDNSServers;
 
     // Only one PsiphonVpn instance may exist at a time, as the underlying
     // psi.Psi and tun2socks implementations each contain global state.
@@ -197,7 +198,7 @@ public class PsiphonTunnel {
         mClientPlatformSuffix = new AtomicReference<String>("");
         mShouldRouteThroughTunnelAutomatically = shouldRouteThroughTunnelAutomatically;
         mActiveNetworkType = new AtomicReference<String>("");
-        mActiveNetworkPrimaryDNSServer = new AtomicReference<String>("");
+        mActiveNetworkDNSServers = new AtomicReference<String>("");
         mNetworkMonitor = new NetworkMonitor(new NetworkMonitor.NetworkChangeListener() {
             @Override
             public void onChanged() {
@@ -207,7 +208,7 @@ public class PsiphonTunnel {
                     mHostService.onDiagnosticMessage("reconnect error: " + e);
                 }
             }
-        }, mActiveNetworkType, mActiveNetworkPrimaryDNSServer, mHostService);
+        }, mActiveNetworkType, mActiveNetworkDNSServers, mHostService);
     }
 
     public Object clone() throws CloneNotSupportedException {
@@ -420,6 +421,11 @@ public class PsiphonTunnel {
                                         // Unused on Android.
                                         return PsiphonTunnel.iPv6Synthesize(IPv4Addr);
                                     }
+
+                                    @Override
+                                    public long hasIPv6Route() {
+                                        return PsiphonTunnel.hasIPv6Route(context, logger);
+                                    }
                                 },
                                 new PsiphonProviderNoticeHandler() {
                                     @Override
@@ -455,8 +461,9 @@ public class PsiphonTunnel {
                                         }
                                     }
                                 },
-                                // Do not use IPv6 synthesizer for android
-                                false);
+                                false,   // Do not use IPv6 synthesizer for Android
+                                true     // Use hasIPv6Route on Android
+                                );
                     } catch (java.lang.Exception e) {
                         callbackQueue.submit(new Runnable() {
                             @Override
@@ -489,8 +496,6 @@ public class PsiphonTunnel {
     private final static String VPN_INTERFACE_NETMASK = "255.255.255.0";
     private final static int VPN_INTERFACE_MTU = 1500;
     private final static int UDPGW_SERVER_PORT = 7300;
-    private final static String DEFAULT_PRIMARY_DNS_SERVER = "8.8.4.4";
-    private final static String DEFAULT_SECONDARY_DNS_SERVER = "8.8.8.8";
 
     // Note: Atomic variables used for getting/setting local proxy port, routing flag, and
     // tun fd, as these functions may be called via PsiphonProvider callbacks. Do not use
@@ -635,18 +640,18 @@ public class PsiphonTunnel {
         }
 
         @Override
-        public String getPrimaryDnsServer() {
-            return mPsiphonTunnel.getPrimaryDnsServer(mHostService.getContext(), mHostService);
+        public String getDNSServersAsString() {
+            return mPsiphonTunnel.getDNSServers(mHostService.getContext(), mHostService);
         }
 
         @Override
-        public String getSecondaryDnsServer() {
-            return PsiphonTunnel.getSecondaryDnsServer();
+        public String iPv6Synthesize(String IPv4Addr) {
+            return PsiphonTunnel.iPv6Synthesize(IPv4Addr);
         }
 
         @Override
-        public String iPv6Synthesize(String IPv4Addr) {
-            return PsiphonTunnel.iPv6Synthesize(IPv4Addr);
+        public long hasIPv6Route() {
+            return PsiphonTunnel.hasIPv6Route(mHostService.getContext(), mHostService);
         }
 
         @Override
@@ -682,35 +687,31 @@ public class PsiphonTunnel {
         return hasConnectivity ? 1 : 0;
     }
 
-    private String getPrimaryDnsServer(Context context, HostLogger logger) {
+    private String getDNSServers(Context context, HostLogger logger) {
 
-        // Use the primary DNS server set by mNetworkMonitor,
-        // mActiveNetworkPrimaryDNSServer, when available. It's the most
-        // reliable mechanism. Otherwise fallback to
-        // getFirstActiveNetworkDnsResolver or DEFAULT_PRIMARY_DNS_SERVER.
+        // Use the DNS servers set by mNetworkMonitor,
+        // mActiveNetworkDNSServers, when available. It's the most reliable
+        // mechanism. Otherwise fallback to getActiveNetworkDNSServers.
         //
-        // mActiveNetworkPrimaryDNSServer is not available on API < 21
-        // (LOLLIPOP). mActiveNetworkPrimaryDNSServer may also be temporarily
+        // mActiveNetworkDNSServers is not available on API < 21
+        // (LOLLIPOP). mActiveNetworkDNSServers may also be temporarily
         // unavailable if the last active network has been lost and no new
         // one has yet replaced it.
 
-        String primaryDNSServer = mActiveNetworkPrimaryDNSServer.get();
-        if (primaryDNSServer != "") {
-            return primaryDNSServer;
+        String servers = mActiveNetworkDNSServers.get();
+        if (servers != "") {
+            return servers;
         }
 
-        String dnsResolver = null;
         try {
-            dnsResolver = getFirstActiveNetworkDnsResolver(context, mVpnMode.get());
+            // Use the workaround, comma-delimited format required for gobind.
+            servers = String.join(",", getActiveNetworkDNSServers(context, mVpnMode.get()));
         } catch (Exception e) {
             logger.onDiagnosticMessage("failed to get active network DNS resolver: " + e.getMessage());
-            dnsResolver = DEFAULT_PRIMARY_DNS_SERVER;
+            // Alternate DNS servers will be provided by psiphon-tunnel-core
+            // config or tactics.
         }
-        return dnsResolver;
-    }
-
-    private static String getSecondaryDnsServer() {
-        return DEFAULT_SECONDARY_DNS_SERVER;
+        return servers;
     }
 
     private static String iPv6Synthesize(String IPv4Addr) {
@@ -718,6 +719,17 @@ public class PsiphonTunnel {
         return IPv4Addr;
     }
 
+    private static long hasIPv6Route(Context context, HostLogger logger) {
+        boolean hasRoute = false;
+        try {
+            hasRoute = hasIPv6Route(context);
+        } catch (Exception e) {
+            logger.onDiagnosticMessage("failed to check IPv6 route: " + e.getMessage());
+        }
+        // TODO: change to bool return value once gobind supports that type
+        return hasRoute ? 1 : 0;
+    }
+
     private static String getNetworkID(Context context) {
 
         // TODO: getActiveNetworkInfo is deprecated in API 29; once
@@ -800,7 +812,8 @@ public class PsiphonTunnel {
                     "",
                     new PsiphonProviderShim(this),
                     isVpnMode(),
-                    false        // Do not use IPv6 synthesizer for android
+                    false,   // Do not use IPv6 synthesizer for Android
+                    true     // Use hasIPv6Route on Android
                     );
         } catch (java.lang.Exception e) {
             throw new Exception("failed to start Psiphon library", e);
@@ -1270,26 +1283,30 @@ public class PsiphonTunnel {
         throw new Exception("no private address available");
     }
 
-    private static String getFirstActiveNetworkDnsResolver(Context context, boolean isVpnMode)
+    private static Collection<String> getActiveNetworkDNSServers(Context context, boolean isVpnMode)
             throws Exception {
 
-        Collection<InetAddress> dnsResolvers = getActiveNetworkDnsResolvers(context, isVpnMode);
-        if (!dnsResolvers.isEmpty()) {
-            String dnsResolver = dnsResolvers.iterator().next().toString();
+        ArrayList<String> servers = new ArrayList<String>();
+        for (InetAddress serverAddress : getActiveNetworkDNSServerAddresses(context, isVpnMode)) {
+            String server = serverAddress.toString();
             // strip the leading slash e.g., "/192.168.1.1"
-            if (dnsResolver.startsWith("/")) {
-                dnsResolver = dnsResolver.substring(1);
+            if (server.startsWith("/")) {
+                server = server.substring(1);
             }
-            return dnsResolver;
+            servers.add(server);
+        }
+
+        if (servers.isEmpty()) {
+            throw new Exception("no active network DNS resolver");
         }
 
-        throw new Exception("no active network DNS resolver");
+        return servers;
     }
 
-    private static Collection<InetAddress> getActiveNetworkDnsResolvers(Context context, boolean isVpnMode)
+    private static Collection<InetAddress> getActiveNetworkDNSServerAddresses(Context context, boolean isVpnMode)
             throws Exception {
 
-        final String errorMessage = "getActiveNetworkDnsResolvers failed";
+        final String errorMessage = "getActiveNetworkDNSServerAddresses failed";
         ArrayList<InetAddress> dnsAddresses = new ArrayList<InetAddress>();
 
         ConnectivityManager connectivityManager =
@@ -1389,6 +1406,47 @@ public class PsiphonTunnel {
         return dnsAddresses;
     }
 
+    private static boolean hasIPv6Route(Context context) throws Exception {
+
+            try {
+                // This logic mirrors the logic in
+                // psiphon/common/resolver.hasRoutableIPv6Interface. That
+                // function currently doesn't work on Android due to Go's
+                // net.InterfaceAddrs failing on Android SDK 30+ (see Go issue
+                // 40569). hasIPv6Route provides the same functionality via a
+                // callback into Java code.
+
+                for (NetworkInterface netInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) {
+                    if (netInterface.isUp() &&
+                        !netInterface.isLoopback() &&
+                        !netInterface.isPointToPoint()) {
+                        for (InetAddress address : Collections.list(netInterface.getInetAddresses())) {
+
+                            // Per https://developer.android.com/reference/java/net/Inet6Address#textual-representation-of-ip-addresses,
+                            // "Java will never return an IPv4-mapped address.
+                            //  These classes can take an IPv4-mapped address as
+                            //  input, both in byte array and text
+                            //  representation. However, it will be converted
+                            //  into an IPv4 address." As such, when the type of
+                            //  the IP address is Inet6Address, this should be
+                            //  an actual IPv6 address.
+
+                            if (address instanceof Inet6Address &&
+                                !address.isLinkLocalAddress() &&
+                                !address.isSiteLocalAddress() &&
+                                !address.isMulticastAddress ()) {
+                                return true;
+                            }
+                        }
+                    }
+                }
+                } catch (SocketException e) {
+                throw new Exception("hasIPv6Route failed", e);
+            }
+
+            return false;
+    }
+
     //----------------------------------------------------------------------------------------------
     // Exception
     //----------------------------------------------------------------------------------------------
@@ -1411,18 +1469,18 @@ public class PsiphonTunnel {
         private final NetworkChangeListener listener;
         private ConnectivityManager.NetworkCallback networkCallback;
         private AtomicReference<String> activeNetworkType;
-        private AtomicReference<String> activeNetworkPrimaryDNSServer;
+        private AtomicReference<String> activeNetworkDNSServers;
         private HostLogger logger;
 
         public NetworkMonitor(
             NetworkChangeListener listener,
             AtomicReference<String> activeNetworkType,
-            AtomicReference<String> activeNetworkPrimaryDNSServer,
+            AtomicReference<String> activeNetworkDNSServers,
             HostLogger logger) {
 
             this.listener = listener;
             this.activeNetworkType = activeNetworkType;
-            this.activeNetworkPrimaryDNSServer = activeNetworkPrimaryDNSServer;
+            this.activeNetworkDNSServers = activeNetworkDNSServers;
             this.logger = logger;
         }
 
@@ -1471,7 +1529,7 @@ public class PsiphonTunnel {
                     if (network == null) {
 
                         activeNetworkType.set("NONE");
-                        activeNetworkPrimaryDNSServer.set("");
+                        activeNetworkDNSServers.set("");
                         logger.onDiagnosticMessage("NetworkMonitor: clear current active network");
 
                     } else {
@@ -1493,22 +1551,24 @@ public class PsiphonTunnel {
                         }
                         activeNetworkType.set(networkType);
 
-                        String primaryDNSServer = "";
+                        ArrayList<String> servers = new ArrayList<String>();
                         try {
                             LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
-                            List<InetAddress> dnsServers = linkProperties.getDnsServers();
-                            if (!dnsServers.isEmpty()) {
-                                primaryDNSServer = dnsServers.iterator().next().toString();
-                                if (primaryDNSServer.startsWith("/")) {
-                                    primaryDNSServer = primaryDNSServer.substring(1);
+                            List<InetAddress> serverAddresses = linkProperties.getDnsServers();
+                            for (InetAddress serverAddress : serverAddresses) {
+                                String server = serverAddress.toString();
+                                if (server.startsWith("/")) {
+                                    server = server.substring(1);
                                 }
+                                servers.add(server);
                             }
                         } catch (java.lang.Exception e) {
                         }
-                        activeNetworkPrimaryDNSServer.set(primaryDNSServer);
+                        // Use the workaround, comma-delimited format required for gobind.
+                        activeNetworkDNSServers.set(String.join(",", servers));
 
                         String message = "NetworkMonitor: set current active network " + networkType;
-                        if (primaryDNSServer != "") {
+                        if (!servers.isEmpty()) {
                             // The DNS server address is potential PII and not logged.
                             message += " with DNS";
                         }

+ 54 - 40
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -38,12 +38,10 @@
 #import "PsiphonClientPlatform.h"
 #import "Redactor.h"
 
-#define GOOGLE_DNS_1 @"8.8.4.4"
-#define GOOGLE_DNS_2 @"8.8.8.8"
-
 NSErrorDomain _Nonnull const PsiphonTunnelErrorDomain = @"com.psiphon3.ios.PsiphonTunnelErrorDomain";
 
 const BOOL UseIPv6Synthesizer = TRUE; // Must always use IPv6Synthesizer for iOS
+const BOOL UseHasIPv6RouteGetter = FALSE;
 
 /// Error codes which can returned by PsiphonTunnel
 typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
@@ -118,10 +116,8 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     _Atomic BOOL usingNoticeFiles;
 
     // DNS
-    NSString *primaryGoogleDNS;
-    NSString *secondaryGoogleDNS;
     _Atomic BOOL useInitialDNS; // initialDNSCache validity flag.
-    NSArray<NSString *> *initialDNSCache;  // This cache becomes void if internetReachabilityChanged is called.
+    NSString *initialDNSCache;  // This cache becomes void if internetReachabilityChanged is called.
     
     // Log timestamp formatter
     // Note: NSDateFormatter is threadsafe.
@@ -143,18 +139,9 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     self->tunnelWholeDevice = FALSE;
     atomic_init(&self->usingNoticeFiles, FALSE);
 
-    // Randomize order of Google DNS servers on start,
-    // and consistently return in that fixed order.
-    if (arc4random_uniform(2) == 0) {
-        self->primaryGoogleDNS = GOOGLE_DNS_1;
-        self->secondaryGoogleDNS = GOOGLE_DNS_2;
-    } else {
-        self->primaryGoogleDNS = GOOGLE_DNS_2;
-        self->secondaryGoogleDNS = GOOGLE_DNS_1;
-    }
-
-    self->initialDNSCache = [self getDNSServers];
-    atomic_init(&self->useInitialDNS, [self->initialDNSCache count] > 0);
+    // Use the workaround, comma-delimited format required for gobind.
+    self->initialDNSCache = [[self getSystemDNSServers] componentsJoinedByString:@","];
+    atomic_init(&self->useInitialDNS, TRUE);
 
     rfc3339Formatter = [PsiphonTunnel rfc3339Formatter];
     
@@ -323,6 +310,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
                 self,
                 self->tunnelWholeDevice, // useDeviceBinder
                 UseIPv6Synthesizer,
+                UseHasIPv6RouteGetter,
                 &e);
             
             if (e != nil) {
@@ -1268,25 +1256,15 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return nil;
 }
 
-- (NSString *)getPrimaryDnsServer {
-    // This function is only called when BindToDevice is used/supported.
+- (NSString *)getDNSServersAsString {
     // TODO: Implement correctly
 
     if (atomic_load(&self->useInitialDNS)) {
-        return self->initialDNSCache[0];
-    } else {
-        return self->primaryGoogleDNS;
-    }
-}
-
-- (NSString *)getSecondaryDnsServer {
-    // This function is only called when BindToDevice is used/supported.
-    // TODO: Implement correctly
-
-    if (atomic_load(&self->useInitialDNS) && [self->initialDNSCache count] > 1) {
-        return self->initialDNSCache[1];
+        return self->initialDNSCache;
     } else {
-        return self->secondaryGoogleDNS;
+        // Alternate DNS servers will be provided by psiphon-tunnel-core
+        // config or tactics.
+        return @"";
     }
 }
 
@@ -1307,6 +1285,11 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return [IPv6Synthesizer IPv4ToIPv6:IPv4Addr];
 }
 
+- (NSString *)hasIPv6Route:()BOOL {
+    // Unused on iOS.
+    return FALSE;
+}
+
 - (NSString *)getNetworkID {
     return [NetworkID getNetworkID:[self->reachability currentReachabilityStatus]];
 }
@@ -1331,14 +1314,31 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     @return Array of DNS addresses, nil on failure.
  */
 
-- (NSArray<NSString *> *)getDNSServers {
+- (NSArray<NSString *> *)getSystemDNSServers {
     NSMutableArray<NSString *> *serverList = [NSMutableArray new];
 
+    // Limitations:
+    //
+    // - May not work on IPv6-only networks:
+    //   https://developer.apple.com/forums/thread/86338.
+    //
+    // - Will not return the DNS servers for the underlying physical network
+    //   once the VPN is running:
+    //   https://developer.apple.com/forums/thread/661601.
+    //
+    // - Potential APIs which return the DNS servers associated with an
+    //   interface (e.g, invoke nw_path_get_dns_servers as part of
+    //   NWPathMonitor) are private:
+    //   https://developer.apple.com/forums/thread/107861.
+    //
+    // - High-level APIs such as resolving and making connections via
+    //   NEPacketTunnelProvider are missing circumvention capabilities.
+
     res_state _state;
     _state = malloc(sizeof(struct __res_state));
 
     if (res_ninit(_state) < 0) {
-        [self logMessage:@"getDNSServers: res_ninit failed."];
+        [self logMessage:@"getSystemDNSServers: res_ninit failed."];
         free(_state);
         return nil;
     }
@@ -1362,7 +1362,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
             if (EXIT_SUCCESS == ret_code) {
                 [serverList addObject:[NSString stringWithUTF8String:hostBuf]];
             } else {
-                [self logMessage:[NSString stringWithFormat: @"getDNSServers: getnameinfo failed: %d", ret_code]];
+                [self logMessage:[NSString stringWithFormat: @"getSystemDNSServers: getnameinfo failed: %d", ret_code]];
             }
         }
     }
@@ -1469,7 +1469,14 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 }
 
 - (void)internetReachabilityChanged:(NSNotification *)note {
-    // Invalidate initialDNSCache.
+
+    // Invalidate initialDNSCache due to limitations documented in
+    // getDNSServers.
+    //
+    // TODO: consider at least reverting to using the initialDNSCache when a
+    // new network ID matches the initial network ID -- i.e., when the device
+    // is back on the initial network -- even though those DNS server _may_
+    // have changed.
     atomic_store(&self->useInitialDNS, FALSE);
 
     Reachability* currentReachability = [note object];
@@ -1738,9 +1745,16 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 
         PsiphonProviderNetwork *networkInfoProvider = [[PsiphonProviderNetwork alloc] init];
 
-        GoPsiStartSendFeedback(psiphonConfig, feedbackJson, uploadPath,
-                               innerFeedbackHandler, networkInfoProvider, noticeHandler,
-                               UseIPv6Synthesizer, &err);
+        GoPsiStartSendFeedback(
+            psiphonConfig,
+            feedbackJson,
+            uploadPath,
+            innerFeedbackHandler,
+            networkInfoProvider,
+            noticeHandler,
+            UseIPv6Synthesizer,
+            UseHasIPv6RouteGetter,
+            &err);
         if (err != nil) {
             NSError *outError = [NSError errorWithDomain:PsiphonTunnelErrorDomain
                                                     code:PsiphonTunnelErrorCodeSendFeedbackError

+ 52 - 19
MobileLibrary/psi/psi.go

@@ -47,14 +47,21 @@ type PsiphonProviderNetwork interface {
 	HasNetworkConnectivity() int
 	GetNetworkID() string
 	IPv6Synthesize(IPv4Addr string) string
+	HasIPv6Route() int
 }
 
 type PsiphonProvider interface {
 	PsiphonProviderNoticeHandler
 	PsiphonProviderNetwork
 	BindToDevice(fileDescriptor int) (string, error)
-	GetPrimaryDnsServer() string
-	GetSecondaryDnsServer() string
+
+	// TODO: move GetDNSServersAsString to PsiphonProviderNetwork to
+	// facilitate custom tunnel-core resolver support in SendFeedback.
+
+	// GetDNSServersAsString must return a comma-delimited list of DNS server
+	// addresses. A single string return value is used since gobind does not
+	// support string slice types.
+	GetDNSServersAsString() string
 }
 
 type PsiphonProviderFeedbackHandler interface {
@@ -115,12 +122,13 @@ var stopController context.CancelFunc
 var controllerWaitGroup *sync.WaitGroup
 
 func Start(
-	configJson,
-	embeddedServerEntryList,
+	configJson string,
+	embeddedServerEntryList string,
 	embeddedServerEntryListFilename string,
 	provider PsiphonProvider,
-	useDeviceBinder,
-	useIPv6Synthesizer bool) error {
+	useDeviceBinder bool,
+	useIPv6Synthesizer bool,
+	useHasIPv6RouteGetter bool) error {
 
 	controllerMutex.Lock()
 	defer controllerMutex.Unlock()
@@ -142,24 +150,29 @@ func Start(
 	// causing unbounded numbers of OS threads to be spawned.
 	// TODO: replace the mutex with a semaphore, to allow a larger but still bounded concurrent
 	// number of calls to the provider?
-	provider = newMutexPsiphonProvider(provider)
+	wrappedProvider := newMutexPsiphonProvider(provider)
 
 	config, err := psiphon.LoadConfig([]byte(configJson))
 	if err != nil {
 		return fmt.Errorf("error loading configuration file: %s", err)
 	}
 
-	config.NetworkConnectivityChecker = provider
+	// Set up callbacks.
 
-	config.NetworkIDGetter = provider
+	config.NetworkConnectivityChecker = wrappedProvider
+	config.NetworkIDGetter = wrappedProvider
+	config.DNSServerGetter = wrappedProvider
 
 	if useDeviceBinder {
-		config.DeviceBinder = provider
-		config.DnsServerGetter = provider
+		config.DeviceBinder = wrappedProvider
 	}
 
 	if useIPv6Synthesizer {
-		config.IPv6Synthesizer = provider
+		config.IPv6Synthesizer = wrappedProvider
+	}
+
+	if useHasIPv6RouteGetter {
+		config.HasIPv6RouteGetter = wrappedProvider
 	}
 
 	// All config fields should be set before calling Commit.
@@ -171,7 +184,7 @@ func Start(
 
 	psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
-			provider.Notice(string(notice))
+			wrappedProvider.Notice(string(notice))
 		}))
 
 	// BuildInfo is a diagnostic notice, so emit only after config.Commit
@@ -368,7 +381,8 @@ func StartSendFeedback(
 	feedbackHandler PsiphonProviderFeedbackHandler,
 	networkInfoProvider PsiphonProviderNetwork,
 	noticeHandler PsiphonProviderNoticeHandler,
-	useIPv6Synthesizer bool) error {
+	useIPv6Synthesizer bool,
+	useHasIPv6RouteGetter bool) error {
 
 	// Cancel any ongoing uploads.
 	StopSendFeedback()
@@ -392,14 +406,27 @@ func StartSendFeedback(
 		return fmt.Errorf("error loading configuration file: %s", err)
 	}
 
-	config.NetworkConnectivityChecker = networkInfoProvider
+	// Set up callbacks.
 
+	config.NetworkConnectivityChecker = networkInfoProvider
 	config.NetworkIDGetter = networkInfoProvider
 
 	if useIPv6Synthesizer {
 		config.IPv6Synthesizer = networkInfoProvider
 	}
 
+	if useHasIPv6RouteGetter {
+		config.HasIPv6RouteGetter = networkInfoProvider
+	}
+
+	// Limitation: config.DNSServerGetter is not set up in the SendFeedback
+	// case, as we don't currently implement network path and system DNS
+	// server monitoring for SendFeedback in the platform code. To ensure we
+	// fallback to the system resolver and don't always use the custom
+	// resolver with alternate DNS servers, clear that config field (this may
+	// still be set via tactics).
+	config.DNSResolverAlternateServers = nil
+
 	// All config fields should be set before calling Commit.
 
 	err = config.Commit(true)
@@ -507,16 +534,22 @@ func (p *mutexPsiphonProvider) IPv6Synthesize(IPv4Addr string) string {
 	return p.p.IPv6Synthesize(IPv4Addr)
 }
 
-func (p *mutexPsiphonProvider) GetPrimaryDnsServer() string {
+func (p *mutexPsiphonProvider) HasIPv6Route() int {
+	p.Lock()
+	defer p.Unlock()
+	return p.p.HasIPv6Route()
+}
+
+func (p *mutexPsiphonProvider) GetDNSServersAsString() string {
 	p.Lock()
 	defer p.Unlock()
-	return p.p.GetPrimaryDnsServer()
+	return p.p.GetDNSServersAsString()
 }
 
-func (p *mutexPsiphonProvider) GetSecondaryDnsServer() string {
+func (p *mutexPsiphonProvider) GetDNSServers() []string {
 	p.Lock()
 	defer p.Unlock()
-	return p.p.GetSecondaryDnsServer()
+	return strings.Split(p.p.GetDNSServersAsString(), ",")
 }
 
 func (p *mutexPsiphonProvider) GetNetworkID() string {

+ 0 - 178
psiphon/LookupIP.go

@@ -1,178 +0,0 @@
-//go:build android || linux || darwin
-// +build android linux darwin
-
-/*
- * Copyright (c) 2015, Psiphon Inc.
- * All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-package psiphon
-
-import (
-	"context"
-	std_errors "errors"
-	"net"
-	"strconv"
-	"syscall"
-
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
-)
-
-// LookupIP resolves a hostname. When BindToDevice is not required, it
-// simply uses net.LookupIP.
-// When BindToDevice is required, LookupIP explicitly creates a UDP
-// socket, binds it to the device, and makes an explicit DNS request
-// to the specified DNS resolver.
-func LookupIP(ctx context.Context, host string, config *DialConfig) ([]net.IP, error) {
-
-	ip := net.ParseIP(host)
-	if ip != nil {
-		return []net.IP{ip}, nil
-	}
-
-	if config.DeviceBinder != nil {
-
-		dnsServer := config.DnsServerGetter.GetPrimaryDnsServer()
-
-		ips, err := bindLookupIP(ctx, host, dnsServer, config)
-		if err == nil {
-			if len(ips) > 0 {
-				return ips, nil
-			}
-			err = std_errors.New("empty address list")
-		}
-
-		if ctx.Err() != nil {
-			// Don't fall through to secondary when the context is cancelled.
-			return ips, errors.Trace(err)
-		}
-
-		dnsServer = config.DnsServerGetter.GetSecondaryDnsServer()
-		if dnsServer == "" {
-			return ips, errors.Trace(err)
-		}
-
-		if GetEmitNetworkParameters() {
-			NoticeWarning("retry resolve host %s: %s", host, err)
-		}
-
-		ips, err = bindLookupIP(ctx, host, dnsServer, config)
-		if err != nil {
-			return nil, errors.Trace(err)
-		}
-		return ips, nil
-	}
-
-	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
-
-	// Remove domain names from "net" error messages.
-	if err != nil && !GetEmitNetworkParameters() {
-		err = RedactNetError(err)
-	}
-
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	ips := make([]net.IP, len(addrs))
-	for i, addr := range addrs {
-		ips[i] = addr.IP
-	}
-
-	return ips, nil
-}
-
-// bindLookupIP implements the BindToDevice LookupIP case.
-// To implement socket device binding, the lower-level syscall APIs are used.
-func bindLookupIP(
-	ctx context.Context, host, dnsServer string, config *DialConfig) ([]net.IP, error) {
-
-	// config.DnsServerGetter.GetDnsServers() must return IP addresses
-	ipAddr := net.ParseIP(dnsServer)
-	if ipAddr == nil {
-		return nil, errors.TraceNew("invalid IP address")
-	}
-
-	// When configured, attempt to synthesize an IPv6 address from
-	// an IPv4 address for compatibility on DNS64/NAT64 networks.
-	// If synthesize fails, try the original address.
-	if config.IPv6Synthesizer != nil && ipAddr.To4() != nil {
-		synthesizedIPAddress := config.IPv6Synthesizer.IPv6Synthesize(dnsServer)
-		if synthesizedIPAddress != "" {
-			synthesizedAddr := net.ParseIP(synthesizedIPAddress)
-			if synthesizedAddr != nil {
-				ipAddr = synthesizedAddr
-			}
-		}
-	}
-
-	dialer := &net.Dialer{
-		Control: func(_, _ string, c syscall.RawConn) error {
-			var controlErr error
-			err := c.Control(func(fd uintptr) {
-
-				socketFD := int(fd)
-
-				_, err := config.DeviceBinder.BindToDevice(socketFD)
-				if err != nil {
-					controlErr = errors.Tracef("BindToDevice failed: %s", err)
-					return
-				}
-			})
-			if controlErr != nil {
-				return errors.Trace(controlErr)
-			}
-			return errors.Trace(err)
-		},
-	}
-
-	netConn, err := dialer.DialContext(
-		ctx, "udp", net.JoinHostPort(ipAddr.String(), strconv.Itoa(DNS_PORT)))
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	type resolveIPResult struct {
-		ips []net.IP
-		err error
-	}
-
-	resultChannel := make(chan resolveIPResult)
-
-	go func() {
-		ips, _, err := ResolveIP(host, netConn)
-		netConn.Close()
-		resultChannel <- resolveIPResult{ips: ips, err: err}
-	}()
-
-	var result resolveIPResult
-
-	select {
-	case result = <-resultChannel:
-	case <-ctx.Done():
-		result.err = ctx.Err()
-		// Interrupt the goroutine
-		netConn.Close()
-		<-resultChannel
-	}
-
-	if result.err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	return result.ips, nil
-}

+ 0 - 55
psiphon/LookupIP_nobind.go

@@ -1,55 +0,0 @@
-// +build !android,!linux,!darwin
-
-/*
- * Copyright (c) 2014, Psiphon Inc.
- * All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-package psiphon
-
-import (
-	"context"
-	"net"
-
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
-)
-
-// LookupIP resolves a hostname.
-func LookupIP(ctx context.Context, host string, config *DialConfig) ([]net.IP, error) {
-
-	if config.DeviceBinder != nil {
-		return nil, errors.TraceNew("LookupIP with DeviceBinder not supported on this platform")
-	}
-
-	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
-
-	// Remove domain names from "net" error messages.
-	if err != nil && !GetEmitNetworkParameters() {
-		err = RedactNetError(err)
-	}
-
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	ips := make([]net.IP, len(addrs))
-	for i, addr := range addrs {
-		ips[i] = addr.IP
-	}
-
-	return ips, nil
-}

+ 107 - 0
psiphon/TCPConn.go

@@ -24,10 +24,12 @@ import (
 	std_errors "errors"
 	"net"
 	"sync/atomic"
+	"syscall"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/upstreamproxy"
 )
 
@@ -202,3 +204,108 @@ func (conn *TCPConn) CloseWrite() (err error) {
 
 	return tcpConn.CloseWrite()
 }
+
+func tcpDial(ctx context.Context, addr string, config *DialConfig) (net.Conn, error) {
+
+	// Get the remote IP and port, resolving a domain name if necessary
+	host, port, err := net.SplitHostPort(addr)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	if config.ResolveIP == nil {
+		// Fail even if we don't need a resolver for this dial: this is a code
+		// misconfiguration.
+		return nil, errors.TraceNew("missing resolver")
+	}
+	ipAddrs, err := config.ResolveIP(ctx, host)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	if len(ipAddrs) < 1 {
+		return nil, errors.TraceNew("no IP address")
+	}
+
+	// When configured, attempt to synthesize IPv6 addresses from
+	// an IPv4 addresses for compatibility on DNS64/NAT64 networks.
+	// If synthesize fails, try the original addresses.
+	if config.IPv6Synthesizer != nil {
+		for i, ipAddr := range ipAddrs {
+			if ipAddr.To4() != nil {
+				synthesizedIPAddress := config.IPv6Synthesizer.IPv6Synthesize(ipAddr.String())
+				if synthesizedIPAddress != "" {
+					synthesizedAddr := net.ParseIP(synthesizedIPAddress)
+					if synthesizedAddr != nil {
+						ipAddrs[i] = synthesizedAddr
+					}
+				}
+			}
+		}
+	}
+
+	// Iterate over a pseudorandom permutation of the destination
+	// IPs and attempt connections.
+	//
+	// Only continue retrying as long as the dial context is not
+	// done. Unlike net.Dial, we do not fractionalize the context
+	// deadline, as the dial is generally intended to apply to a
+	// single attempt. So these serial retries are most useful in
+	// cases of immediate failure, such as "no route to host"
+	// errors when a host resolves to both IPv4 and IPv6 but IPv6
+	// addresses are unreachable.
+	//
+	// Retries at higher levels cover other cases: e.g.,
+	// Controller.remoteServerListFetcher will retry its entire
+	// operation and tcpDial will try a new permutation; or similarly,
+	// Controller.establishCandidateGenerator will retry a candidate
+	// tunnel server dials.
+
+	permutedIndexes := prng.Perm(len(ipAddrs))
+
+	lastErr := errors.TraceNew("unknown error")
+
+	for _, index := range permutedIndexes {
+
+		dialer := &net.Dialer{
+			Control: func(_, _ string, c syscall.RawConn) error {
+				var controlErr error
+				err := c.Control(func(fd uintptr) {
+
+					socketFD := int(fd)
+
+					setAdditionalSocketOptions(socketFD)
+
+					if config.BPFProgramInstructions != nil {
+						err := setSocketBPF(config.BPFProgramInstructions, socketFD)
+						if err != nil {
+							controlErr = errors.Tracef("setSocketBPF failed: %s", err)
+							return
+						}
+					}
+
+					if config.DeviceBinder != nil {
+						_, err := config.DeviceBinder.BindToDevice(socketFD)
+						if err != nil {
+							controlErr = errors.Tracef("BindToDevice failed: %s", err)
+							return
+						}
+					}
+				})
+				if controlErr != nil {
+					return errors.Trace(controlErr)
+				}
+				return errors.Trace(err)
+			},
+		}
+
+		conn, err := dialer.DialContext(
+			ctx, "tcp", net.JoinHostPort(ipAddrs[index].String(), port))
+		if err != nil {
+			lastErr = errors.Trace(err)
+			continue
+		}
+
+		return &TCPConn{Conn: conn}, nil
+	}
+
+	return nil, lastErr
+}

+ 0 - 133
psiphon/TCPConn_bind.go

@@ -1,133 +0,0 @@
-//go:build !windows
-// +build !windows
-
-/*
- * Copyright (c) 2015, Psiphon Inc.
- * All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-package psiphon
-
-import (
-	"context"
-	"math/rand"
-	"net"
-	"syscall"
-
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
-)
-
-// tcpDial is the platform-specific part of DialTCP
-func tcpDial(ctx context.Context, addr string, config *DialConfig) (net.Conn, error) {
-
-	// Get the remote IP and port, resolving a domain name if necessary
-	host, port, err := net.SplitHostPort(addr)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-	ipAddrs, err := LookupIP(ctx, host, config)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-	if len(ipAddrs) < 1 {
-		return nil, errors.TraceNew("no IP address")
-	}
-
-	// When configured, attempt to synthesize IPv6 addresses from
-	// an IPv4 addresses for compatibility on DNS64/NAT64 networks.
-	// If synthesize fails, try the original addresses.
-	if config.IPv6Synthesizer != nil {
-		for i, ipAddr := range ipAddrs {
-			if ipAddr.To4() != nil {
-				synthesizedIPAddress := config.IPv6Synthesizer.IPv6Synthesize(ipAddr.String())
-				if synthesizedIPAddress != "" {
-					synthesizedAddr := net.ParseIP(synthesizedIPAddress)
-					if synthesizedAddr != nil {
-						ipAddrs[i] = synthesizedAddr
-					}
-				}
-			}
-		}
-	}
-
-	// Iterate over a pseudorandom permutation of the destination
-	// IPs and attempt connections.
-	//
-	// Only continue retrying as long as the dial context is not
-	// done. Unlike net.Dial, we do not fractionalize the context
-	// deadline, as the dial is generally intended to apply to a
-	// single attempt. So these serial retries are most useful in
-	// cases of immediate failure, such as "no route to host"
-	// errors when a host resolves to both IPv4 and IPv6 but IPv6
-	// addresses are unreachable.
-	//
-	// Retries at higher levels cover other cases: e.g.,
-	// Controller.remoteServerListFetcher will retry its entire
-	// operation and tcpDial will try a new permutation; or similarly,
-	// Controller.establishCandidateGenerator will retry a candidate
-	// tunnel server dials.
-
-	permutedIndexes := rand.Perm(len(ipAddrs))
-
-	lastErr := errors.TraceNew("unknown error")
-
-	for _, index := range permutedIndexes {
-
-		dialer := &net.Dialer{
-			Control: func(_, _ string, c syscall.RawConn) error {
-				var controlErr error
-				err := c.Control(func(fd uintptr) {
-
-					socketFD := int(fd)
-
-					setAdditionalSocketOptions(socketFD)
-
-					if config.BPFProgramInstructions != nil {
-						err := setSocketBPF(config.BPFProgramInstructions, socketFD)
-						if err != nil {
-							controlErr = errors.Tracef("setSocketBPF failed: %s", err)
-							return
-						}
-					}
-
-					if config.DeviceBinder != nil {
-						_, err := config.DeviceBinder.BindToDevice(socketFD)
-						if err != nil {
-							controlErr = errors.Tracef("BindToDevice failed: %s", err)
-							return
-						}
-					}
-				})
-				if controlErr != nil {
-					return errors.Trace(controlErr)
-				}
-				return errors.Trace(err)
-			},
-		}
-
-		conn, err := dialer.DialContext(
-			ctx, "tcp", net.JoinHostPort(ipAddrs[index].String(), port))
-		if err != nil {
-			lastErr = errors.Trace(err)
-			continue
-		}
-
-		return &TCPConn{Conn: conn}, nil
-	}
-
-	return nil, lastErr
-}

+ 6 - 1
psiphon/UDPConn.go

@@ -54,7 +54,12 @@ func NewUDPConn(
 		return nil, nil, errors.Tracef("invalid destination port: %d", port)
 	}
 
-	ipAddrs, err := LookupIP(ctx, host, config)
+	if config.ResolveIP == nil {
+		// Fail even if we don't need a resolver for this dial: this is a code
+		// misconfiguration.
+		return nil, nil, errors.TraceNew("missing resolver")
+	}
+	ipAddrs, err := config.ResolveIP(ctx, host)
 	if err != nil {
 		return nil, nil, errors.Trace(err)
 	}

+ 15 - 25
psiphon/TCPConn_nobind.go → psiphon/common/parameters/labeledCIDRs.go

@@ -1,7 +1,5 @@
-// +build windows
-
 /*
- * Copyright (c) 2015, Psiphon Inc.
+ * Copyright (c) 2022, Psiphon Inc.
  * All rights reserved.
  *
  * This program is free software: you can redistribute it and/or modify
@@ -19,34 +17,26 @@
  *
  */
 
-package psiphon
+package parameters
 
 import (
-	"context"
 	"net"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 )
 
-// tcpDial is the platform-specific part of DialTCP
-func tcpDial(ctx context.Context, addr string, config *DialConfig) (net.Conn, error) {
-
-	if config.DeviceBinder != nil {
-		return nil, errors.TraceNew("psiphon.interruptibleTCPDial with DeviceBinder not supported")
+// LabeledCIDRs consists of lists of CIDRs referenced by a label value.
+type LabeledCIDRs map[string][]string
+
+// Validate checks that the CIDR values are well-formed.
+func (c LabeledCIDRs) Validate() error {
+	for _, CIDRs := range c {
+		for _, CIDR := range CIDRs {
+			_, _, err := net.ParseCIDR(CIDR)
+			if err != nil {
+				return errors.Trace(err)
+			}
+		}
 	}
-
-	dialer := net.Dialer{}
-
-	conn, err := dialer.DialContext(ctx, "tcp", addr)
-
-	// Remove domain names from "net" error messages.
-	if err != nil && !GetEmitNetworkParameters() {
-		err = RedactNetError(err)
-	}
-
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	return &TCPConn{Conn: conn}, nil
+	return nil
 }

+ 62 - 4
psiphon/common/parameters/parameters.go

@@ -65,6 +65,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"golang.org/x/net/bpf"
 )
 
@@ -217,6 +218,8 @@ const (
 	ReplayTargetUpstreamBytes                        = "ReplayTargetUpstreamBytes"
 	ReplayTargetDownstreamBytes                      = "ReplayTargetDownstreamBytes"
 	ReplayTargetTunnelDuration                       = "ReplayTargetTunnelDuration"
+	ReplayLaterRoundMoveToFrontProbability           = "ReplayLaterRoundMoveToFrontProbability"
+	ReplayRetainFailedProbability                    = "ReplayRetainFailedProbability"
 	ReplayBPF                                        = "ReplayBPF"
 	ReplaySSH                                        = "ReplaySSH"
 	ReplayObfuscatorPadding                          = "ReplayObfuscatorPadding"
@@ -232,9 +235,8 @@ const (
 	ReplayLivenessTest                               = "ReplayLivenessTest"
 	ReplayUserAgent                                  = "ReplayUserAgent"
 	ReplayAPIRequestPadding                          = "ReplayAPIRequestPadding"
-	ReplayLaterRoundMoveToFrontProbability           = "ReplayLaterRoundMoveToFrontProbability"
-	ReplayRetainFailedProbability                    = "ReplayRetainFailedProbability"
 	ReplayHoldOffTunnel                              = "ReplayHoldOffTunnel"
+	ReplayResolveParameters                          = "ReplayResolveParameters"
 	APIRequestUpstreamPaddingMinBytes                = "APIRequestUpstreamPaddingMinBytes"
 	APIRequestUpstreamPaddingMaxBytes                = "APIRequestUpstreamPaddingMaxBytes"
 	APIRequestDownstreamPaddingMinBytes              = "APIRequestDownstreamPaddingMinBytes"
@@ -301,6 +303,17 @@ const (
 	RestrictFrontingProviderIDsClientProbability     = "RestrictFrontingProviderIDsClientProbability"
 	UpstreamProxyAllowAllServerEntrySources          = "UpstreamProxyAllowAllServerEntrySources"
 	DestinationBytesMetricsASN                       = "DestinationBytesMetricsASN"
+	DNSResolverAttemptsPerServer                     = "DNSResolverAttemptsPerServer"
+	DNSResolverRequestTimeout                        = "DNSResolverRequestTimeout"
+	DNSResolverAwaitTimeout                          = "DNSResolverAwaitTimeout"
+	DNSResolverPreresolvedIPAddressCIDRs             = "DNSResolverPreresolvedIPAddressCIDRs"
+	DNSResolverPreresolvedIPAddressProbability       = "DNSResolverPreresolvedIPAddressProbability"
+	DNSResolverAlternateServers                      = "DNSResolverAlternateServers"
+	DNSResolverPreferAlternateServerProbability      = "DNSResolverPreferAlternateServerProbability"
+	DNSResolverProtocolTransformSpecs                = "DNSResolverProtocolTransformSpecs"
+	DNSResolverProtocolTransformScopedSpecNames      = "DNSResolverProtocolTransformScopedSpecNames"
+	DNSResolverProtocolTransformProbability          = "DNSResolverProtocolTransformProbability"
+	DNSResolverIncludeEDNS0Probability               = "DNSResolverIncludeEDNS0Probability"
 )
 
 const (
@@ -535,6 +548,8 @@ var defaultParameters = map[string]struct {
 	ReplayTargetUpstreamBytes:              {value: 0, minimum: 0},
 	ReplayTargetDownstreamBytes:            {value: 0, minimum: 0},
 	ReplayTargetTunnelDuration:             {value: 1 * time.Second, minimum: time.Duration(0)},
+	ReplayLaterRoundMoveToFrontProbability: {value: 0.0, minimum: 0.0},
+	ReplayRetainFailedProbability:          {value: 0.5, minimum: 0.0},
 	ReplayBPF:                              {value: true},
 	ReplaySSH:                              {value: true},
 	ReplayObfuscatorPadding:                {value: true},
@@ -550,9 +565,8 @@ var defaultParameters = map[string]struct {
 	ReplayLivenessTest:                     {value: true},
 	ReplayUserAgent:                        {value: true},
 	ReplayAPIRequestPadding:                {value: true},
-	ReplayLaterRoundMoveToFrontProbability: {value: 0.0, minimum: 0.0},
-	ReplayRetainFailedProbability:          {value: 0.5, minimum: 0.0},
 	ReplayHoldOffTunnel:                    {value: true},
+	ReplayResolveParameters:                {value: true},
 
 	APIRequestUpstreamPaddingMinBytes:   {value: 0, minimum: 0},
 	APIRequestUpstreamPaddingMaxBytes:   {value: 1024, minimum: 0},
@@ -637,6 +651,18 @@ var defaultParameters = map[string]struct {
 	UpstreamProxyAllowAllServerEntrySources: {value: false},
 
 	DestinationBytesMetricsASN: {value: "", flags: serverSideOnly},
+
+	DNSResolverAttemptsPerServer:                {value: 2, minimum: 1},
+	DNSResolverRequestTimeout:                   {value: 5 * time.Second, minimum: 100 * time.Millisecond, flags: useNetworkLatencyMultiplier},
+	DNSResolverAwaitTimeout:                     {value: 100 * time.Millisecond, minimum: 1 * time.Millisecond, flags: useNetworkLatencyMultiplier},
+	DNSResolverPreresolvedIPAddressCIDRs:        {value: LabeledCIDRs{}},
+	DNSResolverPreresolvedIPAddressProbability:  {value: 0.0, minimum: 0.0},
+	DNSResolverAlternateServers:                 {value: []string{}},
+	DNSResolverPreferAlternateServerProbability: {value: 0.0, minimum: 0.0},
+	DNSResolverProtocolTransformSpecs:           {value: transforms.Specs{}},
+	DNSResolverProtocolTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
+	DNSResolverProtocolTransformProbability:     {value: 0.0, minimum: 0.0},
+	DNSResolverIncludeEDNS0Probability:          {value: 0.0, minimum: 0.0},
 }
 
 // IsServerSideOnly indicates if the parameter specified by name is used
@@ -961,6 +987,14 @@ func (p *Parameters) Set(
 					}
 					return nil, errors.Trace(err)
 				}
+			case LabeledCIDRs:
+				err := v.Validate()
+				if err != nil {
+					if skipOnError {
+						continue
+					}
+					return nil, errors.Trace(err)
+				}
 			}
 
 			// Enforce any minimums. Assumes defaultParameters[name]
@@ -1447,3 +1481,27 @@ func (p ParametersAccessor) TunnelProtocolPortLists(name string) TunnelProtocolP
 	p.snapshot.getValue(name, &value)
 	return value
 }
+
+// LabeledCIDRs returns a CIDR string list parameter value corresponding to
+// the specified labeled set and label value. The return value is nil when no
+// set is found.
+func (p ParametersAccessor) LabeledCIDRs(name, label string) []string {
+	value := LabeledCIDRs{}
+	p.snapshot.getValue(name, &value)
+	return value[label]
+}
+
+// ProtocolTransformSpecs returns a transforms.Specs parameter value.
+func (p ParametersAccessor) ProtocolTransformSpecs(name string) transforms.Specs {
+	value := transforms.Specs{}
+	p.snapshot.getValue(name, &value)
+	return value
+}
+
+// ProtocolTransformScopedSpecNames returns a transforms.ScopedSpecNames
+// parameter value.
+func (p ParametersAccessor) ProtocolTransformScopedSpecNames(name string) transforms.ScopedSpecNames {
+	value := transforms.ScopedSpecNames{}
+	p.snapshot.getValue(name, &value)
+	return value
+}

+ 173 - 0
psiphon/common/redact.go

@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2022, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package common
+
+import (
+	std_errors "errors"
+	"net/url"
+	"path/filepath"
+	"regexp"
+	"strings"
+)
+
+// RedactURLError transforms an error, when it is a url.Error, removing
+// the URL value. This is to avoid logging private user data in cases
+// where the URL may be a user input value.
+// This function is used with errors returned by net/http and net/url,
+// which are (currently) of type url.Error. In particular, the round trip
+// function used by our HttpProxy, http.Client.Do, returns errors of type
+// url.Error, with the URL being the url sent from the user's tunneled
+// applications:
+// https://github.com/golang/go/blob/release-branch.go1.4/src/net/http/client.go#L394
+func RedactURLError(err error) error {
+	if urlErr, ok := err.(*url.Error); ok {
+		err = &url.Error{
+			Op:  urlErr.Op,
+			URL: "",
+			Err: urlErr.Err,
+		}
+	}
+	return err
+}
+
+var redactIPAddressAndPortRegex = regexp.MustCompile(
+	// IP address
+	`(` +
+		// IPv4
+		//
+		// An IPv4 address can also be represented as an unsigned integer, or with
+		// octal or with hex octet values, but we do not check for any of these
+		// uncommon representations as some may match non-IP values and we don't
+		// expect the "net" package, etc., to emit them.)
+
+		`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|` +
+
+		// IPv6
+		//
+		// Optional brackets for IPv6 with port
+		`\[?` +
+		`(` +
+		// Uncompressed IPv6; ensure there are 8 segments to avoid matching, e.g., a
+		// timestamp
+		`(([a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4})|` +
+		// Compressed IPv6
+		`([a-fA-F0-9:]*::[a-fA-F0-9:]+)|([a-fA-F0-9:]+::[a-fA-F0-9:]*)` +
+		`)` +
+		// Optional mapped/translated/embeded IPv4 suffix
+		`(.\d{1,3}\.\d{1,3}\.\d{1,3})?` +
+		`\]?` +
+		`)` +
+
+		// Optional port number
+		`(:\d+)?`)
+
+// RedactIPAddresses returns a copy of the input with all IP addresses (and
+// optional ports) replaced by "[redacted]". This is intended to be used to
+// redact addresses from "net" package I/O error messages and otherwise avoid
+// inadvertently recording direct server IPs via error message logs; and, in
+// metrics, to reduce the error space due to superfluous source port data.
+//
+// RedactIPAddresses uses a simple regex match which liberally matches IP
+// address-like patterns and will match invalid addresses; for example, it
+// will match port numbers greater than 65535. We err on the side of redaction
+// and are not as concerned, in this context, with false positive matches. If
+// a user configures an upstream proxy address with an invalid IP or port
+// value, we prefer to redact it.
+//
+// See the redactIPAddressAndPortRegex comment for some uncommon IP address
+// representations that are not matched.
+func RedactIPAddresses(b []byte) []byte {
+	return redactIPAddressAndPortRegex.ReplaceAll(b, []byte("[redacted]"))
+}
+
+// RedactIPAddressesString is RedactIPAddresses for strings.
+func RedactIPAddressesString(s string) string {
+	return redactIPAddressAndPortRegex.ReplaceAllString(s, "[redacted]")
+}
+
+// EscapeRedactIPAddressString escapes the IP or IP:port addresses in the
+// input in such a way that they won't be redacted when part of the input to
+// RedactIPAddresses.
+//
+// The escape encoding is not guaranteed to be reversable or suitable for
+// machine processing; the goal is to simply ensure the original value is
+// human readable.
+func EscapeRedactIPAddressString(address string) string {
+	address = strings.ReplaceAll(address, ".", "\\.")
+	address = strings.ReplaceAll(address, ":", "\\:")
+	return address
+}
+
+var redactFilePathRegex = regexp.MustCompile(
+	// File path
+	`(` +
+		// Leading characters
+		`[^ ]*` +
+		// At least one path separator
+		`/` +
+		// Path component; take until next space
+		`[^ ]*` +
+		`)+`)
+
+// RedactFilePaths returns a copy of the input with all file paths
+// replaced by "[redacted]". First any occurrences of the provided file paths
+// are replaced and then an attempt is made to replace any other file paths by
+// searching with a heuristic. The latter is a best effort attempt it is not
+// guaranteed that it will catch every file path.
+func RedactFilePaths(s string, filePaths ...string) string {
+	for _, filePath := range filePaths {
+		s = strings.ReplaceAll(s, filePath, "[redacted]")
+	}
+	return redactFilePathRegex.ReplaceAllLiteralString(filepath.ToSlash(s), "[redacted]")
+}
+
+// RedactFilePathsError is RedactFilePaths for errors.
+func RedactFilePathsError(err error, filePaths ...string) error {
+	return std_errors.New(RedactFilePaths(err.Error(), filePaths...))
+}
+
+// RedactNetError removes network address information from a "net" package
+// error message. Addresses may be domains or IP addresses.
+//
+// Limitations: some non-address error context can be lost; this function
+// makes assumptions about how the Go "net" package error messages are
+// formatted and will fail to redact network addresses if this assumptions
+// become untrue.
+func RedactNetError(err error) error {
+
+	// Example "net" package error messages:
+	//
+	// - lookup <domain>: no such host
+	// - lookup <domain>: No address associated with hostname
+	// - dial tcp <address>: connectex: No connection could be made because the target machine actively refused it
+	// - write tcp <address>-><address>: write: connection refused
+
+	if err == nil {
+		return err
+	}
+
+	errstr := err.Error()
+	index := strings.Index(errstr, ": ")
+	if index == -1 {
+		return err
+	}
+
+	return std_errors.New("[redacted]" + errstr[index:])
+}

+ 34 - 5
psiphon/utils_test.go → psiphon/common/redact_test.go

@@ -17,7 +17,7 @@
  *
  */
 
-package psiphon
+package common
 
 import (
 	"os"
@@ -25,78 +25,107 @@ import (
 	"testing"
 )
 
-func TestStripIPAddresses(t *testing.T) {
+func TestRedactIPAddresses(t *testing.T) {
 
 	testCases := []struct {
 		description    string
 		input          string
 		expectedOutput string
+		escape         bool
 	}{
 		{
 			"IPv4 address",
 			"prefix 192.168.0.1 suffix",
 			"prefix [redacted] suffix",
+			false,
 		},
 		{
 			"IPv6 address",
 			"prefix 2001:0db8:0000:0000:0000:ff00:0042:8329 suffix",
 			"prefix [redacted] suffix",
+			false,
 		},
 		{
 			"Remove leading zeros IPv6 address",
 			"prefix 2001:db8:0:0:0:ff00:42:8329 suffix",
 			"prefix [redacted] suffix",
+			false,
 		},
 		{
 			"Omit consecutive zeros sections IPv6 address",
 			"prefix 2001:db8::ff00:42:8329 suffix",
 			"prefix [redacted] suffix",
+			false,
 		},
 		{
 			"IPv4 mapped/translated/embedded address",
 			"prefix 0::ffff:192.168.0.1, 0::ffff:0:192.168.0.1, 64:ff9b::192.168.0.1 suffix",
 			"prefix [redacted], [redacted], [redacted] suffix",
+			false,
 		},
 		{
 			"IPv4 address and port",
 			"read tcp 127.0.0.1:1025->127.0.0.1:8000: use of closed network connection",
 			"read tcp [redacted]->[redacted]: use of closed network connection",
+			false,
 		},
 		{
 			"IPv6 address and port",
 			"read tcp [2001:db8::ff00:42:8329]:1025->[2001:db8::ff00:42:8329]:8000: use of closed network connection",
 			"read tcp [redacted]->[redacted]: use of closed network connection",
+			false,
 		},
 		{
 			"Loopback IPv6 address and invalid port number",
 			"dial tcp [::1]:88888: network is unreachable",
 			"dial tcp [redacted]: network is unreachable",
+			false,
 		},
 		{
 			"Numbers and periods",
 			"prefix 192. 168. 0. 1 suffix",
 			"prefix 192. 168. 0. 1 suffix",
+			false,
 		},
 		{
 			"Hex string and colon",
 			"prefix 0123456789abcdef: suffix",
 			"prefix 0123456789abcdef: suffix",
+			false,
 		},
 		{
 			"Colons",
 			"prefix :: suffix",
 			"prefix :: suffix",
+			false,
 		},
 		{
 			"Notice",
 			`{"data":{"SSHClientVersion":"SSH-2.0-C","candidateNumber":0,"diagnosticID":"se0XVQ/4","dialPortNumber":"4000","establishedTunnelsCount":0,"isReplay":false,"networkLatencyMultiplier":2.8284780852763953,"networkType":"WIFI","protocol":"OSSH","region":"US","upstream_ossh_padding":7077},"noticeType":"ConnectedServer","timestamp":"2020-12-16T14:07:02.030Z"}`,
 			`{"data":{"SSHClientVersion":"SSH-2.0-C","candidateNumber":0,"diagnosticID":"se0XVQ/4","dialPortNumber":"4000","establishedTunnelsCount":0,"isReplay":false,"networkLatencyMultiplier":2.8284780852763953,"networkType":"WIFI","protocol":"OSSH","region":"US","upstream_ossh_padding":7077},"noticeType":"ConnectedServer","timestamp":"2020-12-16T14:07:02.030Z"}`,
+			false,
+		},
+		{
+			"escape IPv4 address and port",
+			"prefix 192.168.0.1:443 suffix",
+			"prefix 192\\.168\\.0\\.1\\:443 suffix",
+			true,
+		},
+		{
+			"escape IPv6 address and port",
+			"prefix [2001:db8::ff00:42:8329]:443 suffix",
+			"prefix [2001\\:db8\\:\\:ff00\\:42\\:8329]\\:443 suffix",
+			true,
 		},
 	}
 
 	for _, testCase := range testCases {
 		t.Run(testCase.description, func(t *testing.T) {
-			output := StripIPAddressesString(testCase.input)
+			input := testCase.input
+			if testCase.escape {
+				input = EscapeRedactIPAddressString(input)
+			}
+			output := RedactIPAddressesString(input)
 			if output != testCase.expectedOutput {
 				t.Errorf("unexpected output: %s", output)
 			}
@@ -104,7 +133,7 @@ func TestStripIPAddresses(t *testing.T) {
 	}
 }
 
-func TestStripFilePaths(t *testing.T) {
+func TestRedactFilePaths(t *testing.T) {
 
 	testCases := []struct {
 		description    string
@@ -184,7 +213,7 @@ func TestStripFilePaths(t *testing.T) {
 			for _, filePath := range testCase.filePaths {
 				filePaths = append(filePaths, strings.ReplaceAll(filePath, "/", string(os.PathSeparator)))
 			}
-			output := StripFilePaths(input, filePaths...)
+			output := RedactFilePaths(input, filePaths...)
 			if output != testCase.expectedOutput {
 				t.Errorf("unexpected output: %s", output)
 			}

+ 1493 - 0
psiphon/common/resolver/resolver.go

@@ -0,0 +1,1493 @@
+/*
+ * Copyright (c) 2022, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// Package resolver implements a DNS stub resolver, or DNS client, which
+// resolves domain names.
+//
+// The resolver is Psiphon-specific and oriented towards blocking resistance.
+// See ResolveIP for more details.
+package resolver
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"net"
+	"sync"
+	"sync/atomic"
+	"syscall"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
+	lrucache "github.com/cognusion/go-cache-lru"
+	"github.com/miekg/dns"
+)
+
+const (
+	resolverCacheDefaultTTL          = 1 * time.Minute
+	resolverCacheReapFrequency       = 1 * time.Minute
+	resolverCacheMaxEntries          = 10000
+	resolverServersUpdateTTL         = 5 * time.Second
+	resolverDefaultAttemptsPerServer = 2
+	resolverDefaultRequestTimeout    = 5 * time.Second
+	resolverDefaultAwaitTimeout      = 100 * time.Millisecond
+	resolverDefaultAnswerTTL         = 1 * time.Minute
+	resolverDNSPort                  = "53"
+	udpPacketBufferSize              = 1232
+)
+
+// NetworkConfig specifies network-level configuration for a Resolver.
+type NetworkConfig struct {
+
+	// GetDNSServers returns a list of system DNS server addresses (IP:port, or
+	// IP only with port 53 assumed), as determined via OS APIs, in priority
+	// order. GetDNSServers may be nil.
+	GetDNSServers func() []string
+
+	// BindToDevice should ensure the input file descriptor, a UDP socket, is
+	// excluded from VPN routing. BindToDevice may be nil.
+	BindToDevice func(fd int) (string, error)
+
+	// IPv6Synthesize should apply NAT64 synthesis to the input IPv4 address,
+	// returning a synthesized IPv6 address that will route to the same
+	// endpoint. IPv6Synthesize may be nil.
+	IPv6Synthesize func(IPv4 string) string
+
+	// HasIPv6Route should return true when the host has an IPv6 route.
+	// Resolver has an internal implementation, hasRoutableIPv6Interface, to
+	// determine this, but it can fail on some platforms ("route ip+net:
+	// netlinkrib: permission denied" on Android, for example; see Go issue
+	// 40569). When HasIPv6Route is nil, the internal implementation is used.
+	HasIPv6Route func() bool
+
+	// LogWarning is an optional callback which is used to log warnings and
+	// transient errors which would otherwise not be recorded or returned.
+	LogWarning func(error)
+
+	// LogHostnames indicates whether to log hostname in errors or not.
+	LogHostnames bool
+}
+
+func (c *NetworkConfig) logWarning(err error) {
+	if c.LogWarning != nil {
+		c.LogWarning(err)
+	}
+}
+
+// ResolveParameters specifies the configuration and behavior of a single
+// ResolveIP call, a single domain name resolution.
+//
+// New ResolveParameters may be generated by calling MakeResolveParameters,
+// which takes tactics parameters as an input.
+//
+// ResolveParameters may be persisted for replay.
+type ResolveParameters struct {
+
+	// AttemptsPerServer specifies how many requests to send to each DNS
+	// server before trying the next server. IPv4 and IPv6 requests are set
+	// concurrently and count as one attempt.
+	AttemptsPerServer int
+
+	// RequestTimeout specifies how long to wait for a valid response before
+	// moving on to the next attempt.
+	RequestTimeout time.Duration
+
+	// AwaitTimeout specifies how long to await an additional response after
+	// the first response is received. This additional wait time applies only
+	// when there is no IPv4 or IPv6 response.
+	AwaitTimeout time.Duration
+
+	// PreresolvedIPAddress specifies an IP address result to be used in place
+	// of making a request.
+	PreresolvedIPAddress string
+
+	// AlternateDNSServer specifies an alterate DNS server (IP:port, or IP
+	// only with port 53 assumed) to be used when either no system DNS
+	// servers are available or when PreferAlternateDNSServer is set.
+	AlternateDNSServer string
+
+	// PreferAlternateDNSServer indicates whether to prioritize using the
+	// AlternateDNSServer. When set, the AlternateDNSServer is attempted
+	// before any system DNS servers.
+	PreferAlternateDNSServer bool
+
+	// ProtocolTransformName specifies the name associated with
+	// ProtocolTransformSpec and is used for metrics.
+	ProtocolTransformName string
+
+	// ProtocolTransformSpec specifies a transform to apply to the DNS request packet.
+	// See: "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms".
+	//
+	// As transforms operate on strings and DNS requests are binary,
+	// transforms should be expressed using hex characters.
+	//
+	// DNS transforms include strategies discovered by the Geneva team,
+	// https://geneva.cs.umd.edu.
+	ProtocolTransformSpec transforms.Spec
+
+	// ProtocolTransformSeed specifies the seed to use for generating random
+	// data in the ProtocolTransformSpec transform. To replay a transform,
+	// specify the same seed.
+	ProtocolTransformSeed *prng.Seed
+
+	// IncludeEDNS0 indicates whether to include the EDNS(0) UDP maximum
+	// response size extension in DNS requests. The resolver can handle
+	// responses larger than 512 bytes (RFC 1035 maximum) regardless of
+	// whether the extension is included; the extension may be included as
+	// part of appearing similar to other DNS traffic.
+	IncludeEDNS0 bool
+
+	firstAttemptWithAnswer int32
+}
+
+// GetFirstAttemptWithAnswer returns the index of the first request attempt
+// that received a valid response, for the most recent ResolveIP call using
+// this ResolveParameters. This information is used for logging metrics. The
+// first attempt has index 1. GetFirstAttemptWithAnswer return 0 when no
+// request attempt has reported a valid response.
+//
+// The caller is responsible for synchronizing use of a ResolveParameters
+// instance (e.g, use a distinct ResolveParameters per ResolveIP to ensure
+// GetFirstAttemptWithAnswer refers to a specific ResolveIP).
+func (r *ResolveParameters) GetFirstAttemptWithAnswer() int {
+	return int(atomic.LoadInt32(&r.firstAttemptWithAnswer))
+}
+
+func (r *ResolveParameters) setFirstAttemptWithAnswer(attempt int) {
+	atomic.StoreInt32(&r.firstAttemptWithAnswer, int32(attempt))
+}
+
+// Implementation note: Go's standard net.Resolver supports specifying a
+// custom Dial function. This could be used to implement at least a large
+// subset of the Resolver functionality on top of Go's standard library
+// resolver. However, net.Resolver is limited to using the CGO resolver on
+// Android, https://github.com/golang/go/issues/8877, in which case the
+// custom Dial function is not used. Furthermore, the the pure Go resolver in
+// net/dnsclient_unix.go appears to not be used on Windows at this time.
+//
+// Go also provides golang.org/x/net/dns/dnsmessage, a DNS message marshaller,
+// which could potentially be used in place of github.com/miekg/dns.
+
+// Resolver is a DNS stub resolver, or DNS client, which resolves domain
+// names. A Resolver instance maintains a cache, a network state snapshot,
+// and metrics. All ResolveIP calls will share the same cache and state.
+// Multiple concurrent ResolveIP calls are supported.
+type Resolver struct {
+	networkConfig *NetworkConfig
+
+	mutex             sync.Mutex
+	networkID         string
+	hasIPv6Route      bool
+	systemServers     []string
+	lastServersUpdate time.Time
+	cache             *lrucache.Cache
+	metrics           resolverMetrics
+}
+
+type resolverMetrics struct {
+	resolves      int
+	cacheHits     int
+	requestsIPv4  int
+	requestsIPv6  int
+	responsesIPv4 int
+	responsesIPv6 int
+	peakInFlight  int64
+	minRTT        time.Duration
+	maxRTT        time.Duration
+}
+
+func newResolverMetrics() resolverMetrics {
+	return resolverMetrics{minRTT: -1}
+}
+
+// NewResolver creates a new Resolver instance.
+func NewResolver(networkConfig *NetworkConfig, networkID string) *Resolver {
+
+	r := &Resolver{
+		networkConfig: networkConfig,
+		metrics:       newResolverMetrics(),
+	}
+
+	// updateNetworkState will initialize the cache and network state,
+	// including system DNS servers.
+	r.updateNetworkState(networkID)
+
+	return r
+}
+
+// Stop clears the Resolver cache and resets metrics. Stop must be called only
+// after ceasing all in-flight ResolveIP goroutines, or else the cache or
+// metrics may repopulate. A Resolver may be resumed after calling Stop, but
+// Update must be called first.
+func (r *Resolver) Stop() {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	// r.networkConfig is not set to nil to avoid possible nil pointer
+	// dereferences by concurrent ResolveIP calls.
+
+	r.networkID = ""
+	r.hasIPv6Route = false
+	r.systemServers = nil
+	r.cache.Flush()
+	r.metrics = newResolverMetrics()
+}
+
+// MakeResolveParameters generates ResolveParameters using the input tactics
+// parameters and optional frontingProviderID context.
+func (r *Resolver) MakeResolveParameters(
+	p parameters.ParametersAccessor,
+	frontingProviderID string) (*ResolveParameters, error) {
+
+	params := &ResolveParameters{
+		AttemptsPerServer: p.Int(parameters.DNSResolverAttemptsPerServer),
+		RequestTimeout:    p.Duration(parameters.DNSResolverRequestTimeout),
+		AwaitTimeout:      p.Duration(parameters.DNSResolverAwaitTimeout),
+	}
+
+	// When a frontingProviderID is specified, generate a pre-resolved IP
+	// address, based on tactics configuration.
+	if frontingProviderID != "" {
+		if p.WeightedCoinFlip(parameters.DNSResolverPreresolvedIPAddressProbability) {
+			CIDRs := p.LabeledCIDRs(parameters.DNSResolverPreresolvedIPAddressCIDRs, frontingProviderID)
+			if len(CIDRs) > 0 {
+				CIDR := CIDRs[prng.Intn(len(CIDRs))]
+				IP, err := generateIPAddressFromCIDR(CIDR)
+				if err != nil {
+					return nil, errors.Trace(err)
+				}
+				params.PreresolvedIPAddress = IP.String()
+			}
+		}
+	}
+
+	// When PreresolvedIPAddress is set, there's no DNS request and the
+	// following params can be skipped.
+	if params.PreresolvedIPAddress != "" {
+		return params, nil
+	}
+
+	// Select an alternate DNS server, typically a public DNS server. Ensure
+	// tactics is configured with an empty DNSResolverAlternateServers list
+	// in cases where attempts to public DNS server are unwanted.
+	alternateServers := p.Strings(parameters.DNSResolverAlternateServers)
+	if len(alternateServers) > 0 {
+
+		alternateServer := alternateServers[prng.Intn(len(alternateServers))]
+
+		// Check that the alternateServer has a well-formed IP address; and add
+		// a default port if none it present.
+		host, _, err := net.SplitHostPort(alternateServer)
+		if err != nil {
+			// Assume the SplitHostPort error is due to missing port.
+			host = alternateServer
+			alternateServer = net.JoinHostPort(alternateServer, resolverDNSPort)
+		}
+		if net.ParseIP(host) == nil {
+			// Log warning and proceed without this DNS server.
+			r.networkConfig.logWarning(
+				errors.TraceNew("invalid alternate DNS server IP address"))
+
+		} else {
+
+			params.AlternateDNSServer = alternateServer
+			params.PreferAlternateDNSServer = p.WeightedCoinFlip(
+				parameters.DNSResolverPreferAlternateServerProbability)
+		}
+
+	}
+
+	// Select a DNS transform. DNS request transforms are "scoped" by
+	// alternate DNS server (IP address without port); that is, when an
+	// alternate DNS server is certain to be attempted first, a transform
+	// associated with and known to work with that DNS server will be
+	// selected. Otherwise, a transform from the default scope
+	// (transforms.SCOPE_ANY == "") is selected.
+	//
+	// In any case, ResolveIP will only apply a transform on the first request
+	// attempt.
+	if p.WeightedCoinFlip(parameters.DNSResolverProtocolTransformProbability) {
+
+		specs := p.ProtocolTransformSpecs(
+			parameters.DNSResolverProtocolTransformSpecs)
+		scopedSpecNames := p.ProtocolTransformScopedSpecNames(
+			parameters.DNSResolverProtocolTransformScopedSpecNames)
+
+		// The alternate DNS server will be the first attempt if
+		// PreferAlternateDNSServer or the list of system DNS servers is empty.
+		//
+		// Limitation: the system DNS server list may change, due to a later
+		// Resolver.update call when ResolveIP is called with these
+		// ResolveParameters.
+		_, systemServers := r.getNetworkState()
+		scope := transforms.SCOPE_ANY
+		if params.AlternateDNSServer != "" &&
+			(params.PreferAlternateDNSServer || len(systemServers) == 0) {
+
+			// Remove the port number, as the scope key is an IP address only.
+			//
+			// TODO: when we only just added the default port above, which is
+			// the common case, we could avoid this extra split.
+			host, _, err := net.SplitHostPort(params.AlternateDNSServer)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			scope = host
+		}
+
+		name, spec := specs.Select(scope, scopedSpecNames)
+
+		if spec != nil {
+			params.ProtocolTransformName = name
+			params.ProtocolTransformSpec = spec
+			var err error
+			params.ProtocolTransformSeed, err = prng.NewSeed()
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+		}
+	}
+
+	if p.WeightedCoinFlip(parameters.DNSResolverIncludeEDNS0Probability) {
+		params.IncludeEDNS0 = true
+	}
+
+	return params, nil
+}
+
+// ResolveAddress splits the input host:port address, calls ResolveIP to
+// resolve the IP address of the host, selects an IP if there are multiple,
+// and returns a rejoined IP:port.
+func (r *Resolver) ResolveAddress(
+	ctx context.Context,
+	networkID string,
+	params *ResolveParameters,
+	address string) (string, error) {
+
+	hostname, port, err := net.SplitHostPort(address)
+	if err != nil {
+		return "", errors.Trace(err)
+	}
+
+	IPs, err := r.ResolveIP(ctx, networkID, params, hostname)
+	if err != nil {
+		return "", errors.Trace(err)
+	}
+
+	return net.JoinHostPort(IPs[prng.Intn(len(IPs))].String(), port), nil
+}
+
+// ResolveIP resolves a domain name.
+//
+// The input params may be nil, in which case default timeouts are used.
+//
+// ResolveIP performs concurrent A and AAAA lookups, returns any valid
+// response IPs, and caches results. An error is returned when there are
+// no valid response IPs.
+//
+// ResolveIP is not a general purpose resolver and is Psiphon-specific. For
+// example, resolved domains are expected to exist; ResolveIP does not
+// fallback to TCP; does not consult any "hosts" file; does not perform RFC
+// 3484 sorting logic (see Go issue 18518); only implements a subset of
+// Go/glibc/resolv.conf(5) resolver parameters (attempts and timeouts, but
+// not rotate, single-request etc.) ResolveIP does not implement singleflight
+// logic, as the Go resolver does, and allows multiple concurrent request for
+// the same domain -- Psiphon won't often resolve the exact same domain
+// multiple times concurrently, and, when it does, there's a circumvention
+// benefit to attempting different DNS servers and protocol transforms.
+//
+// ResolveIP does not currently support DoT, DoH, or TCP; those protocols are
+// often blocked or less common. Instead, ResolveIP makes a best effort to
+// evade plaintext UDP DNS interference by ignoring invalid responses and by
+// optionally applying protocol transforms that may evade blocking.
+func (r *Resolver) ResolveIP(
+	ctx context.Context,
+	networkID string,
+	params *ResolveParameters,
+	hostname string) ([]net.IP, error) {
+
+	// ResolveIP does _not_ lock r.mutex for the lifetime of the function, to
+	// ensure many ResolveIP calls can run concurrently.
+
+	// Call updateNetworkState immediately before resolving, as a best effort
+	// to ensure that system DNS servers and IPv6 routing network state
+	// reflects the current network. updateNetworkState locks the Resolver
+	// mutex for its duration, and so concurrent ResolveIP calls may block at
+	// this point. However, all updateNetworkState operations are local to
+	// the host or device; and, if the networkID is unchanged since the last
+	// call, updateNetworkState may not perform any operations; and after the
+	// updateNetworkState call, ResolveIP proceeds without holding the mutex
+	// lock. As a result, this step should not prevent ResolveIP concurrency.
+	r.updateNetworkState(networkID)
+
+	if params == nil {
+		// Supply default ResolveParameters
+		params = &ResolveParameters{
+			AttemptsPerServer: resolverDefaultAttemptsPerServer,
+			RequestTimeout:    resolverDefaultRequestTimeout,
+			AwaitTimeout:      resolverDefaultAwaitTimeout,
+		}
+	}
+
+	// If the hostname is already an IP address, just return that. For
+	// metrics, this does not count as a resolve, as the caller may invoke
+	// ResolveIP for all dials.
+	IP := net.ParseIP(hostname)
+	if IP != nil {
+		return []net.IP{IP}, nil
+	}
+
+	// Count all resolves of an actual domain, including cached and
+	// pre-resolved cases.
+	r.updateMetricResolves()
+
+	// When PreresolvedIPAddress is set, tactics parameters determined the IP address
+	// in this case.
+	if params.PreresolvedIPAddress != "" {
+		IP := net.ParseIP(params.PreresolvedIPAddress)
+		if IP == nil {
+			// Unexpected case, as MakeResolveParameters selects the IP address.
+			return nil, errors.TraceNew("invalid IP address")
+		}
+		return []net.IP{IP}, nil
+	}
+
+	// Use a snapshot of the current network state, including IPv6 routing and
+	// system DNS servers.
+	//
+	// Limitation: these values are used even if the network changes in the
+	// middle of a ResolveIP call; ResolveIP is not interrupted if the
+	// network changes.
+	hasIPv6Route, systemServers := r.getNetworkState()
+
+	// Use the standard library resolver when there's no GetDNSServers, or the
+	// system server list is otherwise empty, and no alternate DNS server is
+	// configured.
+	//
+	// Note that in the case where there are no system DNS servers and there
+	// is an AlternateDNSServer, if the AlternateDNSServer attempt fails,
+	// control does not flow back to defaultResolverLookupIP. On platforms
+	// without GetDNSServers, the caller must arrange for distinct attempts
+	// that try a AlternateDNSServer, or just use the standard library
+	// resolver.
+	//
+	// ResolveIP should always be called, even when defaultResolverLookupIP
+	// will be used, to ensure correct metrics counts and ensure a consistent
+	// error message log stack for all DNS-related failures.
+	if len(systemServers) == 0 && params.AlternateDNSServer == "" {
+		IPs, err := defaultResolverLookupIP(ctx, hostname, r.networkConfig.LogHostnames)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		return IPs, err
+	}
+
+	// Consult the cache before making queries. This comes after the standard
+	// library case, to allow the standard library to provide its own caching
+	// logic.
+	IPs := r.getCache(hostname)
+	if IPs != nil {
+		return IPs, nil
+	}
+
+	// Set the list of DNS servers to attempt. AlternateDNSServer is used
+	// first when PreferAlternateDNSServer is set; otherwise
+	// AlternateDNSServer is used only when there is no system DNS server.
+	var servers []string
+	if params.AlternateDNSServer != "" &&
+		(len(systemServers) == 0 || params.PreferAlternateDNSServer) {
+		servers = []string{params.AlternateDNSServer}
+	}
+	servers = append(servers, systemServers...)
+	if len(servers) == 0 {
+		return nil, errors.TraceNew("no DNS servers")
+	}
+
+	// Set the request timeout and set up a reusable timer for handling
+	// request and await timeouts.
+	//
+	// We expect to always have a request timeout. Handle the unexpected no
+	// timeout, 0, case by setting the longest timeout possible, ~290 years;
+	// always having a non-zero timeout makes the following code marginally
+	// simpler.
+	requestTimeout := params.RequestTimeout
+	if requestTimeout == 0 {
+		requestTimeout = 1<<63 - 1
+	}
+	var timer *time.Timer
+	timerDrained := true
+	resetTimer := func(timeout time.Duration) {
+		if timer == nil {
+			timer = time.NewTimer(timeout)
+		} else {
+			if !timerDrained && !timer.Stop() {
+				<-timer.C
+			}
+			timer.Reset(timeout)
+		}
+		timerDrained = false
+	}
+
+	// Orchestrate the DNS requests
+
+	resolveCtx, cancelFunc := context.WithCancel(ctx)
+	defer cancelFunc()
+	waitGroup := new(sync.WaitGroup)
+	conns := common.NewConns()
+	type answer struct {
+		attempt int
+		IPs     []net.IP
+		TTLs    []time.Duration
+	}
+	maxAttempts := len(servers) * params.AttemptsPerServer
+	answerChan := make(chan *answer, maxAttempts*2)
+	inFlight := int64(0)
+	awaitA := int32(1)
+	awaitAAAA := int32(1)
+	if !hasIPv6Route {
+		awaitAAAA = 0
+	}
+	var result *answer
+	var lastErr atomic.Value
+
+	stop := false
+	for i := 0; !stop && i < maxAttempts; i++ {
+
+		// Limitation: AttemptsPerServer applies for all servers, including
+		// the AlternateDNSSever. So in the PreferAlternateDNSServer case,
+		// that many attempts are made before falling back to system DNS servers.
+
+		server := servers[i/params.AttemptsPerServer]
+
+		// Only the first attempt pair tries transforms, as it's not certain
+		// the transforms will be compatible with DNS servers.
+		useProtocolTransform := (i == 0 && params.ProtocolTransformSpec != nil)
+
+		// Send A and AAAA requests concurrently.
+		questionTypes := []resolverQuestionType{resolverQuestionTypeA, resolverQuestionTypeAAAA}
+		if !hasIPv6Route {
+			questionTypes = questionTypes[0:1]
+		}
+
+		for _, questionType := range questionTypes {
+
+			waitGroup.Add(1)
+
+			// For metrics, track peak concurrent in-flight requests for
+			// a _single_ ResolveIP. inFlight for this ResolveIP is also used
+			// to determine whether to await additional responses once the
+			// first, valid response is received. For that logic to be
+			// correct, we must increment inFlight in this outer goroutine to
+			// ensure the await logic sees either inFlight > 0 or an answer
+			// in the channel.
+			r.updateMetricPeakInFlight(atomic.AddInt64(&inFlight, 1))
+
+			go func(attempt int, questionType resolverQuestionType, useProtocolTransform bool) {
+				defer waitGroup.Done()
+
+				// We must decrement inFlight only after sending an answer and
+				// setting awaitA or awaitAAAA to ensure that the await logic
+				// in the outer goroutine will see inFlight 0 only once those
+				// operations are complete.
+				//
+				// We cannot wait and decrement inFlight when the outer
+				// goroutine receives answers, as no answer is sent in some
+				// cases, such as when the resolve fails due to NXDOMAIN.
+				defer atomic.AddInt64(&inFlight, -1)
+
+				// The request count metric counts the _intention_ to send
+				// requests, as there's a possibility that newResolverConn or
+				// performDNSQuery fail locally before sending a request packet.
+				switch questionType {
+				case resolverQuestionTypeA:
+					r.updateMetricRequestsIPv4()
+				case resolverQuestionTypeAAAA:
+					r.updateMetricRequestsIPv6()
+				}
+
+				// While it's possible, and potentially more optimal, to use
+				// the same UDP socket for both the A and AAAA request, we
+				// use a distinct socket per request, as common DNS clients do.
+				conn, err := r.newResolverConn(r.networkConfig.logWarning, server)
+				if err != nil {
+					lastErr.Store(errors.Trace(err))
+					return
+				}
+				defer conn.Close()
+
+				// There's no context.Context support in the underlying API
+				// used by performDNSQuery, so instead collect all the
+				// request conns so that they can be closed, and any blocking
+				// network I/O interrupted, below, if resolveCtx is done.
+				if !conns.Add(conn) {
+					// Add fails when conns is already closed.
+					return
+				}
+
+				// performDNSQuery will send the request and read a response.
+				// performDNSQuery will continue reading responses until it
+				// receives a valid response, which can mitigate a subset of
+				// DNS injection attacks (to the limited extent possible for
+				// plaintext DNS).
+				//
+				// For IPv4, NXDOMAIN or a response with no IPs is not
+				// expected for domains resolved by Psiphon, so
+				// performDNSQuery treats such a response as invalid. For
+				// IPv6, a response with no IPs, may be valid(even though the
+				// response could be forged); the resolver will continue its
+				// attempts loop if it has no other IPs.
+				//
+				// Each performDNSQuery has no timeout and runs
+				// until it has read a valid response or the requestCtx is
+				// done. This allows for slow arriving, valid responses to
+				// eventually succeed, even if the read time exceeds
+				// requestTimeout, as long as the read time is less than the
+				// requestCtx timeout.
+				//
+				// With this approach, the overall ResolveIP call may have
+				// more than 2 performDNSQuery requests in-flight at a time,
+				// as requestTimeout is used to schedule sending the next
+				// attempt but not cancel the current attempt. For
+				// connectionless UDP, the resulting network traffic should
+				// be similar to common DNS clients which do cancel request
+				// before beginning the next attempt.
+				IPs, TTLs, RTT, err := performDNSQuery(
+					resolveCtx,
+					r.networkConfig.logWarning,
+					params,
+					useProtocolTransform,
+					conn,
+					questionType,
+					hostname)
+
+				// Update the min/max RTT metric when reported (>=0) even if
+				// the result is an error; i.e., the even if there was an
+				// invalid response.
+				//
+				// Limitation: since individual requests aren't cancelled
+				// after requestTimeout, RTT metrics won't reflect
+				// no-response cases, although request and response count
+				// disparities will still show up in the metrics.
+				if RTT >= 0 {
+					r.updateMetricRTT(RTT)
+				}
+
+				if err != nil {
+					lastErr.Store(errors.Trace(err))
+					return
+				}
+
+				if len(IPs) > 0 {
+					select {
+					case answerChan <- &answer{attempt: attempt, IPs: IPs, TTLs: TTLs}:
+					default:
+					}
+				}
+
+				// Mark no longer awaiting A or AAAA as long as there is a
+				// valid response, even if there are no IPs in the IPv6 case.
+				switch questionType {
+				case resolverQuestionTypeA:
+					r.updateMetricResponsesIPv4()
+					atomic.StoreInt32(&awaitA, 0)
+				case resolverQuestionTypeAAAA:
+					r.updateMetricResponsesIPv6()
+					atomic.StoreInt32(&awaitAAAA, 0)
+				default:
+				}
+
+			}(i+1, questionType, useProtocolTransform)
+		}
+
+		resetTimer(requestTimeout)
+
+		select {
+		case result = <-answerChan:
+			// When the first answer, a response with valid IPs, arrives, exit
+			// the attempts loop. The following await branch may collect
+			// additional answers.
+			params.setFirstAttemptWithAnswer(result.attempt)
+			stop = true
+		case <-timer.C:
+			// When requestTimeout arrives, loop around and launch the next
+			// attempt; leave the existing requests running in case they
+			// eventually respond.
+			timerDrained = true
+		case <-resolveCtx.Done():
+			// When resolveCtx is done, exit the attempts loop.
+			//
+			// Append the existing lastErr, which may convey useful
+			// information to be reported in a failed_tunnel error message.
+			lastErr.Store(errors.Tracef("%v (lastErr: %v)", ctx.Err(), lastErr.Load()))
+			stop = true
+		}
+	}
+
+	// Receive any additional answers, now present in the channel, which
+	// arrived concurrent with the first answer. This receive avoids a race
+	// condition where inFlight may now be 0, with additional answers
+	// enqueued, in which case the following await branch is not taken.
+	select {
+	case nextAnswer := <-answerChan:
+		result.IPs = append(result.IPs, nextAnswer.IPs...)
+		result.TTLs = append(result.TTLs, nextAnswer.TTLs...)
+	default:
+	}
+
+	// When we have an answer, await -- for a short time,
+	// params.AwaitTimeout -- extra answers from any remaining in-flight
+	// requests. Only await if the request isn't cancelled and we don't
+	// already have at least one IPv4 and one IPv6 response; only await AAAA
+	// if it was sent; note that a valid AAAA response may include no IPs
+	// lastErr is not set in timeout/cancelled cases here, since we already
+	// have an answer.
+	if result != nil &&
+		resolveCtx.Err() == nil &&
+		atomic.LoadInt64(&inFlight) > 0 &&
+		(atomic.LoadInt32(&awaitA) != 0 || atomic.LoadInt32(&awaitAAAA) != 0) ||
+		params.AwaitTimeout > 0 {
+
+		resetTimer(params.AwaitTimeout)
+
+		for {
+
+			stop := false
+			select {
+			case nextAnswer := <-answerChan:
+				result.IPs = append(result.IPs, nextAnswer.IPs...)
+				result.TTLs = append(result.TTLs, nextAnswer.TTLs...)
+			case <-timer.C:
+				timerDrained = true
+				stop = true
+			case <-resolveCtx.Done():
+				stop = true
+			}
+
+			if stop ||
+				atomic.LoadInt64(&inFlight) == 0 ||
+				(atomic.LoadInt32(&awaitA) == 0 && atomic.LoadInt32(&awaitAAAA) == 0) {
+				break
+			}
+		}
+	}
+
+	timer.Stop()
+
+	// Interrupt all workers.
+	cancelFunc()
+	conns.CloseAll()
+	waitGroup.Wait()
+
+	// When there's no answer, return the last error.
+	if result == nil {
+		err := lastErr.Load()
+		if err == nil {
+			err = errors.TraceNew("unexpected missing error")
+		}
+		if r.networkConfig.LogHostnames {
+			err = fmt.Errorf("resolve %s : %w", hostname, err.(error))
+		}
+		return nil, errors.Trace(err.(error))
+	}
+
+	if len(result.IPs) == 0 {
+		// Unexpected, since a len(IPs) > 0 check precedes sending to answerChan.
+		return nil, errors.TraceNew("unexpected no IPs")
+	}
+
+	// Update the cache now, after all results are gathered.
+	r.setCache(hostname, result.IPs, result.TTLs)
+
+	return result.IPs, nil
+}
+
+// GetMetrics returns a summary of DNS metrics.
+func (r *Resolver) GetMetrics() string {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	// When r.metrics.minRTT < 0, min/maxRTT is unset.
+	minRTT := "n/a"
+	maxRTT := minRTT
+	if r.metrics.minRTT >= 0 {
+		minRTT = fmt.Sprintf("%d", r.metrics.minRTT/time.Millisecond)
+		maxRTT = fmt.Sprintf("%d", r.metrics.maxRTT/time.Millisecond)
+	}
+
+	return fmt.Sprintf("resolves %d | hit %d | req v4/v6 %d/%d | resp %d/%d | peak %d | rtt %s - %s ms.",
+		r.metrics.resolves,
+		r.metrics.cacheHits,
+		r.metrics.requestsIPv4,
+		r.metrics.requestsIPv6,
+		r.metrics.responsesIPv4,
+		r.metrics.responsesIPv6,
+		r.metrics.peakInFlight,
+		minRTT,
+		maxRTT)
+}
+
+// updateNetworkState updates the system DNS server list, IPv6 state, and the
+// cache.
+//
+// Any errors that occur while querying network state are logged; in error
+// conditions the functionality of the resolver may be reduced, but the
+// resolver remains operational.
+func (r *Resolver) updateNetworkState(networkID string) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	// Only perform blocking/expensive update operations when necessary.
+	updateAll := false
+	updateIPv6Route := false
+	updateServers := false
+	flushCache := false
+
+	// If r.cache is nil, this is the first update call in NewResolver. Create
+	// the cache and perform all updates.
+	if r.cache == nil {
+		r.cache = lrucache.NewWithLRU(
+			resolverCacheDefaultTTL,
+			resolverCacheReapFrequency,
+			resolverCacheMaxEntries)
+		updateAll = true
+	}
+
+	// Perform all updates when the networkID has changed, which indicates a
+	// different network.
+	if r.networkID != networkID {
+		updateAll = true
+	}
+
+	if updateAll {
+		updateIPv6Route = true
+		updateServers = true
+		flushCache = true
+	}
+
+	// Even when the networkID has not changed, update DNS servers
+	// periodically. This is similar to how other DNS clients
+	// poll /etc/resolv.conf, including the period of 5s.
+	if time.Since(r.lastServersUpdate) > resolverServersUpdateTTL {
+		updateServers = true
+	}
+
+	// Update hasIPv6Route, which indicates whether the current network has an
+	// IPv6 route and so if DNS requests for AAAA records will be sent.
+	// There's no use for AAAA records on IPv4-only networks; and other
+	// common DNS clients omit AAAA requests on IPv4-only records, so these
+	// requests would otherwise be unusual.
+	//
+	// There's no hasIPv4Route as we always need to resolve A records,
+	// particularly for IPv4-only endpoints; for IPv6-only networks,
+	// NetworkConfig.IPv6Synthesize should be used to accomodate IPv4 DNS
+	// server addresses, and dials performed outside the Resolver will
+	// similarly use NAT 64 (on iOS; on Android, 464XLAT will handle this
+	// transparently).
+	if updateIPv6Route {
+
+		if r.networkConfig.HasIPv6Route != nil {
+
+			r.hasIPv6Route = r.networkConfig.HasIPv6Route()
+
+		} else {
+
+			hasIPv6Route, err := hasRoutableIPv6Interface()
+			if err != nil {
+				// Log warning and proceed without IPv6.
+				r.networkConfig.logWarning(
+					errors.Tracef("unable to determine IPv6 route: %v", err))
+				hasIPv6Route = false
+			}
+			r.hasIPv6Route = hasIPv6Route
+		}
+	}
+
+	// Update the list of system DNS servers. It's not an error condition here
+	// if the list is empty: a subsequent ResolveIP may use
+	// ResolveParameters which specifies an AlternateDNSServer.
+	if updateServers && r.networkConfig.GetDNSServers != nil {
+
+		systemServers := []string{}
+		for _, systemServer := range r.networkConfig.GetDNSServers() {
+			host, _, err := net.SplitHostPort(systemServer)
+			if err != nil {
+				// Assume the SplitHostPort error is due to systemServer being
+				// an IP only, and append the default port, 53. If
+				// systemServer _isn't_ an IP, the following ParseIP will fail.
+				host = systemServer
+				systemServer = net.JoinHostPort(systemServer, resolverDNSPort)
+			}
+			if net.ParseIP(host) == nil {
+				// Log warning and proceed without this DNS server.
+				r.networkConfig.logWarning(
+					errors.TraceNew("invalid DNS server IP address"))
+				continue
+			}
+			systemServers = append(systemServers, systemServer)
+		}
+
+		// Check if the list of servers has changed, including order. If
+		// changed, flush the cache even if the networkID has not changed.
+		// Cached results are only considered valid as long as the system DNS
+		// configuration remains the same.
+		equal := len(r.systemServers) == len(systemServers)
+		if equal {
+			for i := 0; i < len(r.systemServers); i++ {
+				if r.systemServers[i] != systemServers[i] {
+					equal = false
+					break
+				}
+			}
+		}
+		flushCache = flushCache || !equal
+
+		// Concurrency note: once the r.systemServers slice is set, the
+		// contents of the backing array must not be modified due to
+		// concurrent ResolveIP calls.
+		r.systemServers = systemServers
+
+		r.lastServersUpdate = time.Now()
+	}
+
+	if flushCache {
+		r.cache.Flush()
+	}
+
+	// Set r.networkID only after all operations complete without errors; if
+	// r.networkID were set earlier, a subsequent
+	// ResolveIP/updateNetworkState call might proceed as if the network
+	// state were updated for the specified network ID.
+	r.networkID = networkID
+}
+
+func (r *Resolver) getNetworkState() (bool, []string) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	return r.hasIPv6Route, r.systemServers
+}
+
+func (r *Resolver) setCache(hostname string, IPs []net.IP, TTLs []time.Duration) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	// The shortest TTL is used. In some cases, a DNS server may omit the TTL
+	// or set a 0 TTL, in which case the default is used.
+	TTL := resolverDefaultAnswerTTL
+	for _, answerTTL := range TTLs {
+		if answerTTL > 0 && answerTTL < TTL {
+			TTL = answerTTL
+		}
+	}
+
+	// Limitation: with concurrent ResolveIPs for the same domain, the last
+	// setCache call determines the cache value. The results are not merged.
+
+	r.cache.Set(hostname, IPs, TTL)
+}
+
+func (r *Resolver) getCache(hostname string) []net.IP {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	entry, ok := r.cache.Get(hostname)
+	if !ok {
+		return nil
+	}
+	r.metrics.cacheHits += 1
+	return entry.([]net.IP)
+}
+
+// newResolverConn creates a UDP socket that will send packets to serverAddr.
+// serverAddr is an IP:port, which allows specifying the port for testing or
+// in rare cases where the port isn't 53.
+func (r *Resolver) newResolverConn(
+	logWarning func(error),
+	serverAddr string) (retConn net.Conn, retErr error) {
+
+	defer func() {
+		if retErr != nil {
+			logWarning(retErr)
+		}
+	}()
+
+	// When configured, attempt to synthesize an IPv6 address from
+	// an IPv4 address for compatibility on DNS64/NAT64 networks.
+	// If synthesize fails, try the original address.
+	if r.networkConfig.IPv6Synthesize != nil {
+		serverIPStr, port, err := net.SplitHostPort(serverAddr)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		serverIP := net.ParseIP(serverIPStr)
+		if serverIP != nil && serverIP.To4() != nil {
+			synthesized := r.networkConfig.IPv6Synthesize(serverIPStr)
+			if synthesized != "" && net.ParseIP(synthesized) != nil {
+				serverAddr = net.JoinHostPort(synthesized, port)
+			}
+		}
+	}
+
+	dialer := &net.Dialer{}
+	if r.networkConfig.BindToDevice != nil {
+		dialer.Control = func(_, _ string, c syscall.RawConn) error {
+			var controlErr error
+			err := c.Control(func(fd uintptr) {
+				_, err := r.networkConfig.BindToDevice(int(fd))
+				if err != nil {
+					controlErr = errors.Tracef("BindToDevice failed: %v", err)
+					return
+				}
+			})
+			if controlErr != nil {
+				return errors.Trace(controlErr)
+			}
+			return errors.Trace(err)
+		}
+	}
+
+	// context.Background is ok in this case as the UDP dial is just a local
+	// syscall to create the socket.
+	conn, err := dialer.DialContext(context.Background(), "udp", serverAddr)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return conn, nil
+}
+
+func (r *Resolver) updateMetricResolves() {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	r.metrics.resolves += 1
+}
+
+func (r *Resolver) updateMetricRequestsIPv4() {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	r.metrics.requestsIPv4 += 1
+}
+
+func (r *Resolver) updateMetricRequestsIPv6() {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	r.metrics.requestsIPv6 += 1
+}
+
+func (r *Resolver) updateMetricResponsesIPv4() {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	r.metrics.responsesIPv4 += 1
+}
+
+func (r *Resolver) updateMetricResponsesIPv6() {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	r.metrics.responsesIPv6 += 1
+}
+
+func (r *Resolver) updateMetricPeakInFlight(inFlight int64) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	if inFlight > r.metrics.peakInFlight {
+		r.metrics.peakInFlight = inFlight
+	}
+}
+
+func (r *Resolver) updateMetricRTT(rtt time.Duration) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	if rtt < 0 {
+		// Ignore invalid input.
+		return
+	}
+
+	// When r.metrics.minRTT < 0, min/maxRTT is unset.
+	if r.metrics.minRTT < 0 || rtt < r.metrics.minRTT {
+		r.metrics.minRTT = rtt
+	}
+
+	if rtt > r.metrics.maxRTT {
+		r.metrics.maxRTT = rtt
+	}
+}
+
+func hasRoutableIPv6Interface() (bool, error) {
+
+	interfaces, err := net.Interfaces()
+	if err != nil {
+		return false, errors.Trace(err)
+	}
+
+	for _, in := range interfaces {
+
+		if (in.Flags&net.FlagUp == 0) ||
+			(in.Flags&(net.FlagLoopback|net.FlagPointToPoint)) != 0 {
+			continue
+		}
+
+		addrs, err := in.Addrs()
+		if err != nil {
+			return false, errors.Trace(err)
+		}
+
+		for _, addr := range addrs {
+			if IPNet, ok := addr.(*net.IPNet); ok &&
+				IPNet.IP.To4() == nil &&
+				!IPNet.IP.IsLinkLocalUnicast() {
+
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+func generateIPAddressFromCIDR(CIDR string) (net.IP, error) {
+	_, IPNet, err := net.ParseCIDR(CIDR)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	// A retry is required, since a CIDR may include broadcast IPs (a.b.c.0) or
+	// other invalid values. The number of retries is limited to ensure we
+	// don't hang in the case of a misconfiguration.
+	for i := 0; i < 10; i++ {
+		randBytes := prng.Bytes(len(IPNet.IP))
+		IP := make(net.IP, len(IPNet.IP))
+		// The 1 bits in the mask must apply to the IP in the CIDR and the 0
+		// bits in the mask are available to randomize.
+		for i := 0; i < len(IP); i++ {
+			IP[i] = (IPNet.IP[i] & IPNet.Mask[i]) | (randBytes[i] & ^IPNet.Mask[i])
+		}
+		if IP.IsGlobalUnicast() && !common.IsBogon(IP) {
+			return IP, nil
+		}
+	}
+	return nil, errors.TraceNew("failed to generate random IP")
+}
+
+type resolverQuestionType int
+
+const (
+	resolverQuestionTypeA    = 0
+	resolverQuestionTypeAAAA = 1
+)
+
+func performDNSQuery(
+	resolveCtx context.Context,
+	logWarning func(error),
+	params *ResolveParameters,
+	useProtocolTransform bool,
+	conn net.Conn,
+	questionType resolverQuestionType,
+	hostname string) ([]net.IP, []time.Duration, time.Duration, error) {
+
+	if useProtocolTransform {
+		if params.ProtocolTransformSpec == nil ||
+			params.ProtocolTransformSeed == nil {
+			return nil, nil, -1, errors.TraceNew("invalid protocol transform configuration")
+		}
+		// miekg/dns expects conn to be a net.PacketConn or else it writes the
+		// TCP length prefix
+		udpConn, ok := conn.(*net.UDPConn)
+		if !ok {
+			return nil, nil, -1, errors.TraceNew("conn is not a *net.UDPConn")
+		}
+		conn = &transformDNSPacketConn{
+			UDPConn:   udpConn,
+			transform: params.ProtocolTransformSpec,
+			seed:      params.ProtocolTransformSeed,
+		}
+	}
+
+	// UDPSize sets the receive buffer to > 512, even when we don't include
+	// EDNS(0), which will mitigate issues with RFC 1035 non-compliant
+	// servers. See Go issue 51127.
+	dnsConn := &dns.Conn{
+		Conn:    conn,
+		UDPSize: udpPacketBufferSize,
+	}
+	defer dnsConn.Close()
+
+	// SetQuestion initializes request.MsgHdr.Id to a random value
+	request := &dns.Msg{MsgHdr: dns.MsgHdr{RecursionDesired: true}}
+	switch questionType {
+	case resolverQuestionTypeA:
+		request.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
+	case resolverQuestionTypeAAAA:
+		request.SetQuestion(dns.Fqdn(hostname), dns.TypeAAAA)
+	default:
+		return nil, nil, -1, errors.TraceNew("unknown DNS request question type")
+	}
+	if params.IncludeEDNS0 {
+		// miekg/dns: "RFC 6891, Section 6.1.1 allows the OPT record to appear
+		// anywhere in the additional record section, but it's usually at the
+		// end..."
+		request.SetEdns0(udpPacketBufferSize, false)
+	}
+
+	startTime := time.Now()
+
+	// Send the DNS request
+	dnsConn.WriteMsg(request)
+
+	// Read and process the DNS response
+	var IPs []net.IP
+	var TTLs []time.Duration
+	var lastErr error
+	RTT := time.Duration(-1)
+	for {
+
+		// Stop when resolveCtx is done; the caller, ResolveIP, will also
+		// close conn, which will interrupt a blocking dnsConn.ReadMsg.
+		if resolveCtx.Err() != nil {
+
+			// ResolveIP, which calls performDNSQuery, already records the
+			// context error (e.g., context timeout), so instead report
+			// lastErr, when present, as it may contain more useful
+			// information about why a response was rejected.
+			err := lastErr
+			if err == nil {
+				err = errors.Trace(resolveCtx.Err())
+			}
+
+			return nil, nil, RTT, err
+		}
+
+		// Read a response. RTT is the elapsed time between sending the
+		// request and reading the last received response.
+		response, err := dnsConn.ReadMsg()
+		RTT = time.Since(startTime)
+		if err == nil && response.MsgHdr.Id != request.MsgHdr.Id {
+			err = dns.ErrId
+		}
+		if err != nil {
+			// Try reading again, in case the first response packet failed to
+			// unmarshal or had an invalid ID. The Go resolver also does this;
+			// see Go issue 13281.
+			if resolveCtx.Err() == nil {
+				// Only log if resolveCtx is not done; otherwise the error could
+				// be due to conn being closed by ResolveIP.
+				lastErr = errors.Tracef("invalid response: %v", err)
+				logWarning(lastErr)
+			}
+			continue
+		}
+
+		// Check the RCode.
+		//
+		// For IPv4, we expect RCodeSuccess as Psiphon will typically only
+		// resolve domains that exist and have a valid IP (when this isn't
+		// the case, and we retry, the overall ResolveIP and its parent dial
+		// will still abort after resolveCtx is done, or RequestTimeout
+		// expires for maxAttempts).
+		//
+		// For IPv6, we should also expect RCodeSuccess even if there is no
+		// AAAA record, as long as the domain exists and has an A record.
+		// However, per RFC 6147 section 5.1.2, we may receive
+		// NXDOMAIN: "...some servers respond with RCODE=3 to a AAAA query
+		// even if there is an A record available for that owner name. Those
+		// servers are in clear violation of the meaning of RCODE 3...". In
+		// this case, we coalesce NXDOMAIN into success to treat the response
+		// the same as success with no AAAA record.
+		//
+		// All other RCodes, which are unexpected, lead to a read retry.
+		if response.MsgHdr.Rcode != dns.RcodeSuccess &&
+			!(questionType == resolverQuestionTypeAAAA && response.MsgHdr.Rcode == dns.RcodeNameError) {
+
+			errMsg, ok := dns.RcodeToString[response.MsgHdr.Rcode]
+			if !ok {
+				errMsg = fmt.Sprintf("Rcode: %d", response.MsgHdr.Rcode)
+			}
+			lastErr = errors.Tracef("unexpected RCode: %v", errMsg)
+			logWarning(lastErr)
+			continue
+		}
+
+		// Extract all IP answers, along with corresponding TTLs for caching.
+		// Perform additional validation, which may lead to another read
+		// retry. However, if _any_ valid IP is found, stop reading and
+		// return that result. Again, the validation is only best effort.
+
+		checkFailed := false
+		for _, answer := range response.Answer {
+			haveAnswer := false
+			var IP net.IP
+			var TTLSec uint32
+			switch questionType {
+			case resolverQuestionTypeA:
+				if a, ok := answer.(*dns.A); ok {
+					IP = a.A
+					TTLSec = a.Hdr.Ttl
+					haveAnswer = true
+				}
+			case resolverQuestionTypeAAAA:
+				if aaaa, ok := answer.(*dns.AAAA); ok {
+					IP = aaaa.AAAA
+					TTLSec = aaaa.Hdr.Ttl
+					haveAnswer = true
+				}
+			}
+			if !haveAnswer {
+				continue
+			}
+			err := checkDNSAnswerIP(IP)
+			if err != nil {
+				checkFailed = true
+				lastErr = errors.Tracef("invalid IP: %v", err)
+				logWarning(lastErr)
+				// Check the next answer
+				continue
+			}
+			IPs = append(IPs, IP)
+			TTLs = append(TTLs, time.Duration(TTLSec)*time.Second)
+		}
+
+		// For IPv4, an IP is expected, as noted in the comment above.
+		//
+		// In potential cases where we resolve a domain that has only an IPv6
+		// address, the concurrent AAAA request will deliver its result to
+		// ResolveIP, and that answer will be selected, so only the "await"
+		// logic will delay the parent dial in that case.
+		if questionType == resolverQuestionTypeA && len(IPs) == 0 && !checkFailed {
+			checkFailed = true
+			lastErr = errors.TraceNew("unexpected empty A response")
+			logWarning(lastErr)
+		}
+
+		// Retry if there are no valid IPs and any error; if no error, this
+		// may be a valid AAAA response with no IPs, in which case return the
+		// result.
+		if len(IPs) == 0 && checkFailed {
+			continue
+		}
+
+		return IPs, TTLs, RTT, nil
+	}
+}
+
+func checkDNSAnswerIP(IP net.IP) error {
+
+	if IP == nil {
+		return errors.TraceNew("IP is nil")
+	}
+
+	// Limitation: this could still be a phony/injected response, it's not
+	// possible to verify with plaintext DNS, but a "bogon" IP is clearly
+	// invalid.
+	if common.IsBogon(IP) {
+		return errors.TraceNew("IP is bogon")
+	}
+
+	// Create a temporary socket bound to the destination IP. This checks
+	// thats the local host has a route to this IP. If not, we'll reject the
+	// IP. This prevents selecting an IP which is guaranteed to fail to dial.
+	// Use UDP as this results in no network traffic; the destination port is
+	// arbitrary. The Go resolver performs a similar operation.
+	//
+	// Limitations:
+	// - We may cache the IP and reuse it without checking routability again;
+	//   the cache should be flushed when network state changes.
+	// - Given that the AAAA is requested only when the host has an IPv6
+	//   route, we don't expect this to often fail with a _valid_ response.
+	//   However, this remains a possibility and in this case,
+	//   performDNSQuery will keep awaiting a response which can trigger
+	//   the "await" logic.
+	conn, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: IP, Port: 443})
+	if err != nil {
+		return errors.Trace(err)
+	}
+	conn.Close()
+
+	return nil
+}
+
+func defaultResolverLookupIP(
+	ctx context.Context, hostname string, logHostnames bool) ([]net.IP, error) {
+
+	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
+
+	if err != nil && !logHostnames {
+		// Remove domain names from "net" error messages.
+		err = common.RedactNetError(err)
+	}
+
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	ips := make([]net.IP, len(addrs))
+	for i, addr := range addrs {
+		ips[i] = addr.IP
+	}
+
+	return ips, nil
+}
+
+// transformDNSPacketConn wraps a *net.UDPConn, intercepting Write calls and
+// applying the specified protocol transform.
+//
+// As transforms operate on strings and DNS requests are binary, the transform
+// should be expressed using hex characters. The DNS packet to be written
+// (input the Write) is converted to hex, transformed, and converted back to
+// binary and then actually written to the UDP socket.
+type transformDNSPacketConn struct {
+	*net.UDPConn
+	transform transforms.Spec
+	seed      *prng.Seed
+}
+
+func (conn *transformDNSPacketConn) Write(b []byte) (int, error) {
+
+	// Limitation: there is no check that a transformed packet remains within
+	// the network packet MTU.
+
+	input := hex.EncodeToString(b)
+	output, err := conn.transform.Apply(conn.seed, input)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+	packet, err := hex.DecodeString(output)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	_, err = conn.UDPConn.Write(packet)
+	if err != nil {
+		// In the error case, don't report bytes written as the number could
+		// exceed the pre-transform length.
+		return 0, errors.Trace(err)
+	}
+
+	// Report the pre-transform length as bytes written, as the caller may check
+	// that the requested len(b) bytes were written.
+	return len(b), nil
+}

+ 722 - 0
psiphon/common/resolver/resolver_test.go

@@ -0,0 +1,722 @@
+/*
+ * Copyright (c) 2022, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package resolver
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
+	"github.com/miekg/dns"
+)
+
+func TestMakeResolveParameters(t *testing.T) {
+	err := runTestMakeResolveParameters()
+	if err != nil {
+		t.Fatalf(errors.Trace(err).Error())
+	}
+}
+
+func TestResolver(t *testing.T) {
+	err := runTestResolver()
+	if err != nil {
+		t.Fatalf(errors.Trace(err).Error())
+	}
+}
+
+func TestPublicDNSServers(t *testing.T) {
+	IPs, metrics, err := runTestPublicDNSServers()
+	if err != nil {
+		t.Fatalf(errors.Trace(err).Error())
+	}
+	t.Logf("IPs: %v", IPs)
+	t.Logf("Metrics: %v", metrics)
+}
+
+func runTestMakeResolveParameters() error {
+
+	frontingProviderID := "frontingProvider"
+	alternateDNSServer := "172.16.0.1"
+	alternateDNSServerWithPort := net.JoinHostPort(alternateDNSServer, resolverDNSPort)
+	transformName := "exampleTransform"
+
+	paramValues := map[string]interface{}{
+		"DNSResolverPreresolvedIPAddressProbability":  1.0,
+		"DNSResolverPreresolvedIPAddressCIDRs":        parameters.LabeledCIDRs{frontingProviderID: []string{exampleIPv4CIDR}},
+		"DNSResolverAlternateServers":                 []string{alternateDNSServer},
+		"DNSResolverPreferAlternateServerProbability": 1.0,
+		"DNSResolverProtocolTransformProbability":     1.0,
+		"DNSResolverProtocolTransformSpecs":           transforms.Specs{transformName: exampleTransform},
+		"DNSResolverProtocolTransformScopedSpecNames": transforms.ScopedSpecNames{alternateDNSServer: []string{transformName}},
+		"DNSResolverIncludeEDNS0Probability":          1.0,
+	}
+
+	params, err := parameters.NewParameters(nil)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	_, err = params.Set("", false, paramValues)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	resolver := NewResolver(&NetworkConfig{}, "")
+	defer resolver.Stop()
+
+	resolverParams, err := resolver.MakeResolveParameters(
+		params.Get(), frontingProviderID)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	// Test: PreresolvedIPAddress
+
+	CIDRContainsIP := func(CIDR, IP string) bool {
+		_, IPNet, _ := net.ParseCIDR(CIDR)
+		return IPNet.Contains(net.ParseIP(IP))
+	}
+
+	if resolverParams.AttemptsPerServer != 2 ||
+		resolverParams.RequestTimeout != 5*time.Second ||
+		resolverParams.AwaitTimeout != 100*time.Millisecond ||
+		!CIDRContainsIP(exampleIPv4CIDR, resolverParams.PreresolvedIPAddress) ||
+		resolverParams.AlternateDNSServer != "" ||
+		resolverParams.PreferAlternateDNSServer != false ||
+		resolverParams.ProtocolTransformName != "" ||
+		resolverParams.ProtocolTransformSpec != nil ||
+		resolverParams.IncludeEDNS0 != false {
+		return errors.Tracef("unexpected resolver parameters: %+v", resolverParams)
+	}
+
+	// Test: additional generateIPAddressFromCIDR cases
+
+	for i := 0; i < 10000; i++ {
+		for _, CIDR := range []string{exampleIPv4CIDR, exampleIPv6CIDR} {
+			IP, err := generateIPAddressFromCIDR(CIDR)
+			if err != nil {
+				return errors.Trace(err)
+			}
+			if !CIDRContainsIP(CIDR, IP.String()) || common.IsBogon(IP) {
+				return errors.Tracef(
+					"invalid generated IP address %v for CIDR %v", IP, CIDR)
+			}
+		}
+	}
+
+	// Test: Alternate/Transform/EDNS(0)
+
+	paramValues["DNSResolverPreresolvedIPAddressProbability"] = 0.0
+
+	_, err = params.Set("", false, paramValues)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	resolverParams, err = resolver.MakeResolveParameters(
+		params.Get(), frontingProviderID)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if resolverParams.AttemptsPerServer != 2 ||
+		resolverParams.RequestTimeout != 5*time.Second ||
+		resolverParams.AwaitTimeout != 100*time.Millisecond ||
+		resolverParams.PreresolvedIPAddress != "" ||
+		resolverParams.AlternateDNSServer != alternateDNSServerWithPort ||
+		resolverParams.PreferAlternateDNSServer != true ||
+		resolverParams.ProtocolTransformName != transformName ||
+		resolverParams.ProtocolTransformSpec == nil ||
+		resolverParams.IncludeEDNS0 != true {
+		return errors.Tracef("unexpected resolver parameters: %+v", resolverParams)
+	}
+
+	// Test: No Alternate/Transform/EDNS(0)
+
+	paramValues["DNSResolverPreferAlternateServerProbability"] = 0.0
+	paramValues["DNSResolverProtocolTransformProbability"] = 0.0
+	paramValues["DNSResolverIncludeEDNS0Probability"] = 0.0
+
+	_, err = params.Set("", false, paramValues)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	resolverParams, err = resolver.MakeResolveParameters(
+		params.Get(), frontingProviderID)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if resolverParams.AttemptsPerServer != 2 ||
+		resolverParams.RequestTimeout != 5*time.Second ||
+		resolverParams.AwaitTimeout != 100*time.Millisecond ||
+		resolverParams.PreresolvedIPAddress != "" ||
+		resolverParams.AlternateDNSServer != alternateDNSServerWithPort ||
+		resolverParams.PreferAlternateDNSServer != false ||
+		resolverParams.ProtocolTransformName != "" ||
+		resolverParams.ProtocolTransformSpec != nil ||
+		resolverParams.IncludeEDNS0 != false {
+		return errors.Tracef("unexpected resolver parameters: %+v", resolverParams)
+	}
+
+	return nil
+}
+
+func runTestResolver() error {
+
+	// noResponseServer will not respond to requests
+	noResponseServer, err := newTestDNSServer(false, false, false)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	defer noResponseServer.stop()
+
+	// invalidIPServer will respond with an invalid IP
+	invalidIPServer, err := newTestDNSServer(true, false, false)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	defer invalidIPServer.stop()
+
+	// okServer will respond to correct requests (expected domain) with the
+	// correct response (expected IPv4 or IPv6 address)
+	okServer, err := newTestDNSServer(true, true, false)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	defer okServer.stop()
+
+	// alternateOkServer behaves like okServer; getRequestCount is used to
+	// confirm that the alternate server was indeed used
+	alternateOkServer, err := newTestDNSServer(true, true, false)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	defer alternateOkServer.stop()
+
+	// transformOkServer behaves like okServer but only responds if the
+	// transform was applied; other servers do not respond if the transform
+	// is applied
+	transformOkServer, err := newTestDNSServer(true, true, true)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	defer transformOkServer.stop()
+
+	servers := []string{noResponseServer.getAddr(), invalidIPServer.getAddr(), okServer.getAddr()}
+
+	networkConfig := &NetworkConfig{
+		GetDNSServers: func() []string { return servers },
+		LogWarning:    func(err error) { fmt.Printf("LogWarning: %v\n", err) },
+	}
+
+	networkID := "networkID-1"
+
+	resolver := NewResolver(networkConfig, networkID)
+	defer resolver.Stop()
+
+	params := &ResolveParameters{
+		AttemptsPerServer: 1,
+		RequestTimeout:    250 * time.Millisecond,
+		AwaitTimeout:      250 * time.Millisecond,
+		IncludeEDNS0:      true,
+	}
+
+	checkResult := func(IPs []net.IP) error {
+		var IPv4, IPv6 net.IP
+		for _, IP := range IPs {
+			if IP.To4() != nil {
+				IPv4 = IP
+			} else {
+				IPv6 = IP
+			}
+		}
+		if IPv4 == nil {
+			return errors.TraceNew("missing IPv4 response")
+		}
+		if IPv4.String() != exampleIPv4 {
+			return errors.TraceNew("unexpected IPv4 response")
+		}
+		if resolver.hasIPv6Route {
+			if IPv6 == nil {
+				return errors.TraceNew("missing IPv6 response")
+			}
+			if IPv6.String() != exampleIPv6 {
+				return errors.TraceNew("unexpected IPv6 response")
+			}
+		}
+		return nil
+	}
+
+	ctx, cancelFunc := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancelFunc()
+
+	// Test: should retry until okServer responds
+
+	IPs, err := resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if resolver.metrics.resolves != 1 ||
+		resolver.metrics.cacheHits != 0 ||
+		resolver.metrics.requestsIPv4 != 3 || resolver.metrics.responsesIPv4 != 1 ||
+		(resolver.hasIPv6Route && (resolver.metrics.requestsIPv6 != 3 || resolver.metrics.responsesIPv6 != 1)) {
+		return errors.Tracef("unexpected metrics: %+v", resolver.metrics)
+	}
+
+	// Test: cached response
+
+	beforeMetrics := resolver.metrics
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if resolver.metrics.resolves != beforeMetrics.resolves+1 ||
+		resolver.metrics.cacheHits != beforeMetrics.cacheHits+1 ||
+		resolver.metrics.requestsIPv4 != beforeMetrics.requestsIPv4 ||
+		resolver.metrics.requestsIPv6 != beforeMetrics.requestsIPv6 {
+		return errors.Tracef("unexpected metrics: %+v", resolver.metrics)
+	}
+
+	// Test: PreresolvedIPAddress
+
+	beforeMetrics = resolver.metrics
+
+	params.PreresolvedIPAddress = exampleIPv4
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if len(IPs) != 1 || IPs[0].String() != exampleIPv4 {
+		return errors.TraceNew("unexpected preresolved response")
+	}
+
+	if resolver.metrics.resolves != beforeMetrics.resolves+1 ||
+		resolver.metrics.cacheHits != beforeMetrics.cacheHits ||
+		resolver.metrics.requestsIPv4 != beforeMetrics.requestsIPv4 ||
+		resolver.metrics.requestsIPv6 != beforeMetrics.requestsIPv6 {
+		return errors.Tracef("unexpected metrics: %+v", resolver.metrics)
+	}
+
+	params.PreresolvedIPAddress = ""
+
+	// Test: change network ID, which must clear cache
+
+	beforeMetrics = resolver.metrics
+
+	networkID = "networkID-2"
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if resolver.metrics.resolves != beforeMetrics.resolves+1 ||
+		resolver.metrics.cacheHits != beforeMetrics.cacheHits {
+		return errors.Tracef("unexpected metrics: %+v", resolver.metrics)
+	}
+
+	// Test: PreferAlternateDNSServer
+
+	if alternateOkServer.getRequestCount() != 0 {
+		return errors.TraceNew("unexpected alternate server request count")
+	}
+
+	resolver.cache.Flush()
+
+	params.AlternateDNSServer = alternateOkServer.getAddr()
+	params.PreferAlternateDNSServer = true
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if alternateOkServer.getRequestCount() < 1 {
+		return errors.TraceNew("unexpected alternate server request count")
+	}
+
+	params.AlternateDNSServer = ""
+	params.PreferAlternateDNSServer = false
+
+	// Test: fall over to AlternateDNSServer when no system servers
+
+	beforeCount := alternateOkServer.getRequestCount()
+
+	previousGetDNSServers := networkConfig.GetDNSServers
+
+	networkConfig.GetDNSServers = func() []string { return nil }
+
+	// Force system servers update
+	networkID = "networkID-3"
+
+	resolver.cache.Flush()
+
+	params.AlternateDNSServer = alternateOkServer.getAddr()
+	params.PreferAlternateDNSServer = false
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if alternateOkServer.getRequestCount() <= beforeCount {
+		return errors.TraceNew("unexpected alterate server request count")
+	}
+
+	// Test: use default, standard resolver when no servers
+
+	resolver.cache.Flush()
+
+	params.AlternateDNSServer = ""
+	params.PreferAlternateDNSServer = false
+
+	if len(resolver.systemServers) != 0 {
+		return errors.TraceNew("unexpected server count")
+	}
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if len(IPs) == 0 {
+		return errors.TraceNew("unexpected response")
+	}
+
+	// Test: ResolveAddress
+
+	networkConfig.GetDNSServers = previousGetDNSServers
+
+	// Force system servers update
+	networkID = "networkID-4"
+
+	domainAddress := net.JoinHostPort(exampleDomain, "443")
+
+	address, err := resolver.ResolveAddress(ctx, networkID, params, domainAddress)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	host, port, err := net.SplitHostPort(address)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	IP := net.ParseIP(host)
+
+	if IP == nil || (host != exampleIPv4 && host != exampleIPv6) || port != "443" {
+		return errors.TraceNew("unexpected response")
+	}
+
+	// Test: protocol transform
+
+	if transformOkServer.getRequestCount() != 0 {
+		return errors.TraceNew("unexpected transform server request count")
+	}
+
+	resolver.cache.Flush()
+
+	params.AlternateDNSServer = transformOkServer.getAddr()
+	params.PreferAlternateDNSServer = true
+
+	seed, err := prng.NewSeed()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	params.ProtocolTransformName = "exampleTransform"
+	params.ProtocolTransformSpec = exampleTransform
+	params.ProtocolTransformSeed = seed
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if transformOkServer.getRequestCount() < 1 {
+		return errors.TraceNew("unexpected transform server request count")
+	}
+
+	params.AlternateDNSServer = ""
+	params.PreferAlternateDNSServer = false
+	params.ProtocolTransformName = ""
+	params.ProtocolTransformSpec = nil
+	params.ProtocolTransformSeed = nil
+
+	// Test: EDNS(0)
+
+	resolver.cache.Flush()
+
+	params.IncludeEDNS0 = true
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = checkResult(IPs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	params.IncludeEDNS0 = false
+
+	// Test: input IP address
+
+	beforeMetrics = resolver.metrics
+
+	resolver.cache.Flush()
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleIPv4)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if len(IPs) != 1 || IPs[0].String() != exampleIPv4 {
+		return errors.TraceNew("unexpected IPv4 response")
+	}
+
+	if resolver.metrics.resolves != beforeMetrics.resolves {
+		return errors.Tracef("unexpected metrics: %+v", resolver.metrics)
+	}
+
+	// Test: cancel context
+
+	resolver.cache.Flush()
+
+	cancelFunc()
+
+	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	return nil
+}
+
+func runTestPublicDNSServers() ([]net.IP, string, error) {
+
+	networkConfig := &NetworkConfig{
+		GetDNSServers: getPublicDNSServers,
+	}
+
+	networkID := "networkID-1"
+
+	resolver := NewResolver(networkConfig, networkID)
+	defer resolver.Stop()
+
+	params := &ResolveParameters{
+		AttemptsPerServer: 1,
+		RequestTimeout:    5 * time.Second,
+		AwaitTimeout:      1 * time.Second,
+		IncludeEDNS0:      true,
+	}
+
+	IPs, err := resolver.ResolveIP(
+		context.Background(), networkID, params, exampleDomain)
+	if err != nil {
+		return nil, "", errors.Trace(err)
+	}
+
+	gotIPv4 := false
+	gotIPv6 := false
+	for _, IP := range IPs {
+		if IP.To4() != nil {
+			gotIPv4 = true
+		} else {
+			gotIPv6 = true
+		}
+	}
+	if !gotIPv4 {
+		return nil, "", errors.TraceNew("missing IPv4 response")
+	}
+	if !gotIPv6 && resolver.hasIPv6Route {
+		return nil, "", errors.TraceNew("missing IPv6 response")
+	}
+
+	return IPs, resolver.GetMetrics(), nil
+}
+
+func getPublicDNSServers() []string {
+	servers := []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}
+	shuffledServers := make([]string, len(servers))
+	for i, j := range prng.Perm(len(servers)) {
+		shuffledServers[i] = servers[j]
+	}
+	return shuffledServers
+}
+
+const (
+	exampleDomain   = "example.com"
+	exampleIPv4     = "93.184.216.34"
+	exampleIPv4CIDR = "93.184.216.0/24"
+	exampleIPv6     = "2606:2800:220:1:248:1893:25c8:1946"
+	exampleIPv6CIDR = "2606:2800:220::/48"
+)
+
+// Set the reserved Z flag
+var exampleTransform = transforms.Spec{[2]string{"^([a-f0-9]{4})0100", "\\$\\{1\\}0140"}}
+
+type testDNSServer struct {
+	respond         bool
+	validResponse   bool
+	expectTransform bool
+	addr            string
+	requestCount    int32
+	server          *dns.Server
+}
+
+func newTestDNSServer(respond, validResponse, expectTransform bool) (*testDNSServer, error) {
+
+	udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	udpConn, err := net.ListenUDP("udp", udpAddr)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	s := &testDNSServer{
+		respond:         respond,
+		validResponse:   validResponse,
+		expectTransform: expectTransform,
+		addr:            udpConn.LocalAddr().String(),
+	}
+
+	server := &dns.Server{
+		PacketConn: udpConn,
+		Handler:    s,
+	}
+
+	s.server = server
+
+	go server.ActivateAndServe()
+
+	return s, nil
+}
+
+func (s *testDNSServer) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
+	atomic.AddInt32(&s.requestCount, 1)
+
+	if !s.respond {
+		return
+	}
+
+	// Check the reserved Z flag
+	if s.expectTransform != r.MsgHdr.Zero {
+		return
+	}
+
+	if len(r.Question) != 1 || r.Question[0].Name != dns.Fqdn(exampleDomain) {
+		return
+	}
+
+	m := new(dns.Msg)
+	m.SetReply(r)
+	m.Answer = make([]dns.RR, 1)
+	if r.Question[0].Qtype == dns.TypeA {
+		IP := net.ParseIP(exampleIPv4)
+		if !s.validResponse {
+			IP = net.ParseIP("127.0.0.1")
+		}
+		m.Answer[0] = &dns.A{
+			Hdr: dns.RR_Header{
+				Name:   r.Question[0].Name,
+				Rrtype: dns.TypeA,
+				Class:  dns.ClassINET,
+				Ttl:    60},
+			A: IP,
+		}
+	} else {
+		IP := net.ParseIP(exampleIPv6)
+		if !s.validResponse {
+			IP = net.ParseIP("::1")
+		}
+		m.Answer[0] = &dns.AAAA{
+			Hdr: dns.RR_Header{
+				Name:   r.Question[0].Name,
+				Rrtype: dns.TypeAAAA,
+				Class:  dns.ClassINET,
+				Ttl:    60},
+			AAAA: IP,
+		}
+	}
+
+	w.WriteMsg(m)
+}
+
+func (s *testDNSServer) getAddr() string {
+	return s.addr
+}
+
+func (s *testDNSServer) getRequestCount() int {
+	return int(atomic.LoadInt32(&s.requestCount))
+}
+
+func (s *testDNSServer) stop() {
+	s.server.PacketConn.Close()
+	s.server.Shutdown()
+}

+ 174 - 0
psiphon/common/transforms/transforms.go

@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2022, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// Package transforms provides a mechanism to define and apply string data
+// transformations, with the transformations defined by regular expressions
+// to match data to be transformed, and regular expression generators to
+// specify additional or replacement data.
+package transforms
+
+import (
+	"regexp"
+	"regexp/syntax"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	regen "github.com/zach-klippenstein/goregen"
+)
+
+const (
+	SCOPE_ANY = ""
+)
+
+// Spec is a transform spec. A spec is a list of individual transforms to be
+// applied in order. Each transform is defined by two elements: a regular
+// expression to by matched against the input; and a regular expression
+// generator which generates new data. Subgroups from the regular expression
+// may be specified in the regular expression generator, and are populated
+// with the subgroup match, and in this way parts of the original matching
+// data may be retained in the transformed data.
+//
+// For example, with the transform [2]string{"([a-b])", "\\$\\
+// {1\\}"c}, substrings consisting of the characters 'a' and 'b' will be
+// transformed into the same substring with a single character 'c' appended.
+type Spec [][2]string
+
+// Specs is a set of named Specs.
+type Specs map[string]Spec
+
+// Validate checks that all entries in a set of Specs is well-formed, with
+// valid regular expressions.
+func (specs Specs) Validate() error {
+	seed, err := prng.NewSeed()
+	if err != nil {
+		return errors.Trace(err)
+	}
+	for _, spec := range specs {
+		// Call Apply to compile/validate the regular expressions and generators.
+		_, err := spec.Apply(seed, "")
+		if err != nil {
+			return errors.Trace(err)
+		}
+	}
+
+	return nil
+}
+
+// ScopedSpecNames groups a list of Specs, referenced by their Spec name, with
+// the group defined by a scope. The meaning of scope depends on the context
+// in which the transforms are to be used.
+//
+// For example, in the context of DNS request transforms, the scope is the DNS
+// server for which a specific group of transforms is known to be effective.
+//
+// The scope name "" is SCOPE_ANY, and matches any input scope name when there
+// is no specific entry for that scope name in ScopedSpecNames.
+type ScopedSpecNames map[string][]string
+
+// Validate checks that the ScopedSpecNames is well-formed and referenced Spec
+// names are defined in the corresponding input specs.
+func (scopedSpecs ScopedSpecNames) Validate(specs Specs) error {
+
+	for _, scoped := range scopedSpecs {
+		for _, specName := range scoped {
+			_, ok := specs[specName]
+			if !ok {
+				return errors.Tracef("undefined spec name: %s", specName)
+			}
+		}
+	}
+
+	return nil
+}
+
+// Select picks a Spec from Specs based on the input scope and scoping rules.
+// If the input scope name is defined in scopedSpecs, that match takes
+// precedence. Otherwise SCOPE_ANY is selected, when present.
+//
+// After the scope is resolved, Select randomly selects from the matching Spec
+// list.
+//
+// Select will return "", nil when no selection can be made.
+func (specs Specs) Select(scope string, scopedSpecs ScopedSpecNames) (string, Spec) {
+
+	if scope != SCOPE_ANY {
+		scoped, ok := scopedSpecs[scope]
+		if ok {
+			// If the specific scope is defined but empty, this means select
+			// nothing -- don't fall through to SCOPE_ANY.
+			if len(scoped) == 0 {
+				return "", nil
+			}
+
+			specName := scoped[prng.Intn(len(scoped))]
+			spec, ok := specs[specName]
+			if !ok {
+				// specName is not found in specs, which should not happen if
+				// Validate passes; select nothing in this case.
+				return "", nil
+			}
+			return specName, spec
+		}
+		// Fall through to SCOPE_ANY.
+	}
+
+	anyScope, ok := scopedSpecs[SCOPE_ANY]
+	if !ok || len(anyScope) == 0 {
+		// No SCOPE_ANY, or SCOPE_ANY is an empty list.
+		return "", nil
+	}
+
+	specName := anyScope[prng.Intn(len(anyScope))]
+	spec, ok := specs[specName]
+	if !ok {
+		return "", nil
+	}
+	return specName, spec
+}
+
+// Apply applies the Spec to the input string, producing the output string.
+//
+// The input seed is used for all random generation. The same seed can be
+// supplied to produce the same output, for replay.
+func (spec Spec) Apply(seed *prng.Seed, input string) (string, error) {
+
+	// TODO: the compiled regexp and regen could be cached, but the seed is an
+	// issue with caching the regen.
+
+	value := input
+	for _, transform := range spec {
+
+		args := &regen.GeneratorArgs{
+			RngSource: prng.NewPRNGWithSeed(seed),
+			Flags:     syntax.OneLine | syntax.NonGreedy,
+		}
+		rg, err := regen.NewGenerator(transform[1], args)
+		if err != nil {
+			panic(err.Error())
+		}
+		replacement := rg.Generate()
+		if err != nil {
+			panic(err.Error())
+		}
+
+		re := regexp.MustCompile(transform[0])
+		value = re.ReplaceAllString(value, replacement)
+	}
+	return value, nil
+}

+ 140 - 0
psiphon/common/transforms/transforms_test.go

@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2022, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package transforms
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+)
+
+func TestTransforms(t *testing.T) {
+	err := runTestTransforms()
+	if err != nil {
+		t.Fatalf(errors.Trace(err).Error())
+	}
+}
+
+func runTestTransforms() error {
+
+	transformNameAny := "exampleTransform1"
+	transformNameScoped := "exampleTransform2"
+	scopeName := "exampleScope"
+
+	specs := Specs{
+		transformNameAny: Spec{[2]string{"x", "y"}},
+		transformNameScoped: Spec{
+			[2]string{"aa", "cc"},
+			[2]string{"bb", "(dd|ee)"},
+			[2]string{"^([c0]{6})", "\\$\\{1\\}ff0"},
+		},
+	}
+
+	scopedSpecs := ScopedSpecNames{
+		SCOPE_ANY: []string{transformNameAny},
+		scopeName: []string{transformNameScoped},
+	}
+
+	// Test: validation
+
+	err := specs.Validate()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = scopedSpecs.Validate(specs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	// Test: select based on scope
+
+	name, spec := specs.Select(SCOPE_ANY, scopedSpecs)
+	if name != transformNameAny || !reflect.DeepEqual(spec, specs[transformNameAny]) {
+		return errors.TraceNew("unexpected select result")
+	}
+
+	name, spec = specs.Select(scopeName, scopedSpecs)
+	if name != transformNameScoped || !reflect.DeepEqual(spec, specs[transformNameScoped]) {
+		return errors.TraceNew("unexpected select result")
+	}
+
+	// Test: correct transform (assumes spec is transformNameScoped)
+
+	seed, err := prng.NewSeed()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	input := "aa0aa0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa"
+	output, err := spec.Apply(seed, input)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if !strings.HasPrefix(output, "cc0cc0ff0") ||
+		strings.IndexAny(output, "ab") != -1 ||
+		strings.IndexAny(output, "de") == -1 {
+		return errors.Tracef("unexpected apply result: %s", output)
+	}
+
+	// Test: same result with same seed
+
+	previousOutput := output
+
+	output, err = spec.Apply(seed, input)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if output != previousOutput {
+		return errors.Tracef("unexpected different apply result")
+	}
+
+	// Test: different result with different seed (with high probability)
+
+	different := false
+	for i := 0; i < 1000; i++ {
+
+		seed, err = prng.NewSeed()
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		output, err = spec.Apply(seed, input)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		if output != previousOutput {
+			different = true
+			break
+		}
+	}
+
+	if !different {
+		return errors.Tracef("unexpected identical apply result")
+	}
+
+	return nil
+}

+ 176 - 14
psiphon/config.go

@@ -39,6 +39,8 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 )
 
 const (
@@ -286,10 +288,15 @@ type Config struct {
 	// doc.
 	IPv6Synthesizer IPv6Synthesizer
 
-	// DnsServerGetter is an interface that enables tunnel-core to call into
+	// HasIPv6RouteGetter is an interface that allows tunnel-core to call into
+	// the host application to determine if the host has an IPv6 route. See:
+	// HasIPv6RouteGetter doc.
+	HasIPv6RouteGetter HasIPv6RouteGetter
+
+	// DNSServerGetter is an interface that enables tunnel-core to call into
 	// the host application to discover the native network DNS server
-	// settings. See: DnsServerGetter doc.
-	DnsServerGetter DnsServerGetter
+	// settings. See: DNSServerGetter doc.
+	DNSServerGetter DNSServerGetter
 
 	// NetworkIDGetter in an interface that enables tunnel-core to call into
 	// the host application to get an identifier for the host's current active
@@ -771,6 +778,20 @@ type Config struct {
 	// QUICDisablePathMTUDiscoveryProbability is for testing purposes.
 	QUICDisablePathMTUDiscoveryProbability *float64
 
+	// DNSResolverAttemptsPerServer and other DNSResolver fields are for
+	// testing purposes.
+	DNSResolverAttemptsPerServer                *int
+	DNSResolverRequestTimeoutMilliseconds       *int
+	DNSResolverAwaitTimeoutMilliseconds         *int
+	DNSResolverPreresolvedIPAddressCIDRs        parameters.LabeledCIDRs
+	DNSResolverPreresolvedIPAddressProbability  *float64
+	DNSResolverAlternateServers                 []string
+	DNSResolverPreferAlternateServerProbability *float64
+	DNSResolverProtocolTransformSpecs           transforms.Specs
+	DNSResolverProtocolTransformScopedSpecNames transforms.ScopedSpecNames
+	DNSResolverProtocolTransformProbability     *float64
+	DNSResolverIncludeEDNS0Probability          *float64
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	//
@@ -789,6 +810,9 @@ type Config struct {
 
 	clientFeatures []string
 
+	resolverMutex sync.Mutex
+	resolver      *resolver.Resolver
+
 	committed bool
 
 	loadTimestamp string
@@ -890,7 +914,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 	if config.DataRootDirectory == "" {
 		wd, err := os.Getwd()
 		if err != nil {
-			return errors.Trace(StripFilePathsError(err))
+			return errors.Trace(common.RedactFilePathsError(err))
 		}
 		config.DataRootDirectory = wd
 	}
@@ -900,7 +924,9 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 	if !common.FileExists(dataDirectoryPath) {
 		err := os.Mkdir(dataDirectoryPath, os.ModePerm)
 		if err != nil {
-			return errors.Tracef("failed to create datastore directory with error: %s", StripFilePathsError(err, dataDirectoryPath))
+			return errors.Tracef(
+				"failed to create datastore directory with error: %s",
+				common.RedactFilePathsError(err, dataDirectoryPath))
 		}
 	}
 
@@ -1007,7 +1033,9 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 	if !common.FileExists(dataStoreDirectoryPath) {
 		err := os.Mkdir(dataStoreDirectoryPath, os.ModePerm)
 		if err != nil {
-			return errors.Tracef("failed to create datastore directory with error: %s", StripFilePathsError(err, dataStoreDirectoryPath))
+			return errors.Tracef(
+				"failed to create datastore directory with error: %s",
+				common.RedactFilePathsError(err, dataStoreDirectoryPath))
 		}
 	}
 
@@ -1016,7 +1044,9 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 	if !common.FileExists(oslDirectoryPath) {
 		err := os.Mkdir(oslDirectoryPath, os.ModePerm)
 		if err != nil {
-			return errors.Tracef("failed to create osl directory with error: %s", StripFilePathsError(err, oslDirectoryPath))
+			return errors.Tracef(
+				"failed to create osl directory with error: %s",
+				common.RedactFilePathsError(err, oslDirectoryPath))
 		}
 	}
 
@@ -1205,24 +1235,32 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 				successfulMigrations += 1
 			}
 		}
-		NoticeInfo(fmt.Sprintf("Config migration: %d/%d legacy files successfully migrated", successfulMigrations, len(migrations)))
+		NoticeInfo(fmt.Sprintf(
+			"Config migration: %d/%d legacy files successfully migrated",
+			successfulMigrations, len(migrations)))
 
 		// Remove OSL directory if empty
 		if config.MigrateObfuscatedServerListDownloadDirectory != "" {
 			files, err := ioutil.ReadDir(config.MigrateObfuscatedServerListDownloadDirectory)
 			if err != nil {
-				NoticeWarning("Error reading OSL directory: %s", errors.Trace(StripFilePathsError(err, config.MigrateObfuscatedServerListDownloadDirectory)))
+				NoticeWarning(
+					"Error reading OSL directory: %s",
+					errors.Trace(common.RedactFilePathsError(err, config.MigrateObfuscatedServerListDownloadDirectory)))
 			} else if len(files) == 0 {
 				err := os.Remove(config.MigrateObfuscatedServerListDownloadDirectory)
 				if err != nil {
-					NoticeWarning("Error deleting empty OSL directory: %s", errors.Trace(StripFilePathsError(err, config.MigrateObfuscatedServerListDownloadDirectory)))
+					NoticeWarning(
+						"Error deleting empty OSL directory: %s",
+						errors.Trace(common.RedactFilePathsError(err, config.MigrateObfuscatedServerListDownloadDirectory)))
 				}
 			}
 		}
 
 		f, err := os.Create(migrationCompleteFilePath)
 		if err != nil {
-			NoticeWarning("Config migration: failed to create migration completed file with error %s", errors.Trace(StripFilePathsError(err, migrationCompleteFilePath)))
+			NoticeWarning(
+				"Config migration: failed to create migration completed file with error %s",
+				errors.Trace(common.RedactFilePathsError(err, migrationCompleteFilePath)))
 		} else {
 			NoticeInfo("Config migration: completed")
 			f.Close()
@@ -1284,6 +1322,20 @@ func (config *Config) SetParameters(tag string, skipOnError bool, applyParameter
 	return nil
 }
 
+// SetResolver sets the current resolver.
+func (config *Config) SetResolver(resolver *resolver.Resolver) {
+	config.resolverMutex.Lock()
+	defer config.resolverMutex.Unlock()
+	config.resolver = resolver
+}
+
+// GetResolver returns the current resolver. May return nil.
+func (config *Config) GetResolver() *resolver.Resolver {
+	config.resolverMutex.Lock()
+	defer config.resolverMutex.Unlock()
+	return config.resolver
+}
+
 // SetDynamicConfig sets the current client sponsor ID and authorizations.
 // Invalid values for sponsor ID are ignored. The caller must not modify the
 // input authorizations slice.
@@ -1754,6 +1806,50 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.QUICDisableClientPathMTUDiscoveryProbability] = *config.QUICDisablePathMTUDiscoveryProbability
 	}
 
+	if config.DNSResolverAttemptsPerServer != nil {
+		applyParameters[parameters.DNSResolverAttemptsPerServer] = *config.DNSResolverAttemptsPerServer
+	}
+
+	if config.DNSResolverRequestTimeoutMilliseconds != nil {
+		applyParameters[parameters.DNSResolverRequestTimeout] = fmt.Sprintf("%dms", *config.DNSResolverRequestTimeoutMilliseconds)
+	}
+
+	if config.DNSResolverAwaitTimeoutMilliseconds != nil {
+		applyParameters[parameters.DNSResolverAwaitTimeout] = fmt.Sprintf("%dms", *config.DNSResolverAwaitTimeoutMilliseconds)
+	}
+
+	if config.DNSResolverPreresolvedIPAddressProbability != nil {
+		applyParameters[parameters.DNSResolverPreresolvedIPAddressProbability] = *config.DNSResolverPreresolvedIPAddressProbability
+	}
+
+	if config.DNSResolverPreresolvedIPAddressCIDRs != nil {
+		applyParameters[parameters.DNSResolverPreresolvedIPAddressCIDRs] = config.DNSResolverPreresolvedIPAddressCIDRs
+	}
+
+	if config.DNSResolverAlternateServers != nil {
+		applyParameters[parameters.DNSResolverAlternateServers] = config.DNSResolverAlternateServers
+	}
+
+	if config.DNSResolverPreferAlternateServerProbability != nil {
+		applyParameters[parameters.DNSResolverPreferAlternateServerProbability] = *config.DNSResolverPreferAlternateServerProbability
+	}
+
+	if config.DNSResolverProtocolTransformSpecs != nil {
+		applyParameters[parameters.DNSResolverProtocolTransformSpecs] = config.DNSResolverProtocolTransformSpecs
+	}
+
+	if config.DNSResolverProtocolTransformScopedSpecNames != nil {
+		applyParameters[parameters.DNSResolverProtocolTransformScopedSpecNames] = config.DNSResolverProtocolTransformScopedSpecNames
+	}
+
+	if config.DNSResolverIncludeEDNS0Probability != nil {
+		applyParameters[parameters.DNSResolverIncludeEDNS0Probability] = *config.DNSResolverIncludeEDNS0Probability
+	}
+
+	if config.DNSResolverProtocolTransformProbability != nil {
+		applyParameters[parameters.DNSResolverProtocolTransformProbability] = *config.DNSResolverProtocolTransformProbability
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 
@@ -1772,7 +1868,6 @@ func (config *Config) setDialParametersHash() {
 	// to be present in test runs only. It remains an important case to discard
 	// replay dial parameters when test config parameters are varied.
 	//
-	//
 	// Hashing the parameter names detects some ambiguous hash cases, such as two
 	// consecutive int64 parameters, one omitted and one not, that are flipped.
 	// The serialization is not completely unambiguous, and the format is
@@ -2090,6 +2185,69 @@ func (config *Config) setDialParametersHash() {
 		binary.Write(hash, binary.LittleEndian, *config.QUICDisablePathMTUDiscoveryProbability)
 	}
 
+	if config.DNSResolverAttemptsPerServer != nil {
+		hash.Write([]byte("DNSResolverAttemptsPerServer"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.DNSResolverAttemptsPerServer))
+	}
+
+	if config.DNSResolverRequestTimeoutMilliseconds != nil {
+		hash.Write([]byte("DNSResolverRequestTimeoutMilliseconds"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.DNSResolverRequestTimeoutMilliseconds))
+	}
+
+	if config.DNSResolverAwaitTimeoutMilliseconds != nil {
+		hash.Write([]byte("DNSResolverAwaitTimeoutMilliseconds"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.DNSResolverAwaitTimeoutMilliseconds))
+	}
+
+	if config.DNSResolverPreresolvedIPAddressCIDRs != nil {
+		hash.Write([]byte("DNSResolverPreresolvedIPAddressCIDRs"))
+		encodedDNSResolverPreresolvedIPAddressCIDRs, _ :=
+			json.Marshal(config.DNSResolverPreresolvedIPAddressCIDRs)
+		hash.Write(encodedDNSResolverPreresolvedIPAddressCIDRs)
+	}
+
+	if config.DNSResolverPreresolvedIPAddressProbability != nil {
+		hash.Write([]byte("DNSResolverPreresolvedIPAddressProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.DNSResolverPreresolvedIPAddressProbability)
+	}
+
+	if config.DNSResolverAlternateServers != nil {
+		hash.Write([]byte("DNSResolverAlternateServers"))
+		for _, server := range config.DNSResolverAlternateServers {
+			hash.Write([]byte(server))
+		}
+	}
+
+	if config.DNSResolverPreferAlternateServerProbability != nil {
+		hash.Write([]byte("DNSResolverPreferAlternateServerProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.DNSResolverPreferAlternateServerProbability)
+	}
+
+	if config.DNSResolverProtocolTransformSpecs != nil {
+		hash.Write([]byte("DNSResolverProtocolTransformSpecs"))
+		encodedDNSResolverProtocolTransformSpecs, _ :=
+			json.Marshal(config.DNSResolverProtocolTransformSpecs)
+		hash.Write(encodedDNSResolverProtocolTransformSpecs)
+	}
+
+	if config.DNSResolverProtocolTransformScopedSpecNames != nil {
+		hash.Write([]byte(""))
+		encodedDNSResolverProtocolTransformScopedSpecNames, _ :=
+			json.Marshal(config.DNSResolverProtocolTransformScopedSpecNames)
+		hash.Write(encodedDNSResolverProtocolTransformScopedSpecNames)
+	}
+
+	if config.DNSResolverProtocolTransformProbability != nil {
+		hash.Write([]byte("DNSResolverProtocolTransformProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.DNSResolverProtocolTransformProbability)
+	}
+
+	if config.DNSResolverIncludeEDNS0Probability != nil {
+		hash.Write([]byte("DNSResolverIncludeEDNS0Probability"))
+		binary.Write(hash, binary.LittleEndian, *config.DNSResolverIncludeEDNS0Probability)
+	}
+
 	config.dialParametersHash = hash.Sum(nil)
 }
 
@@ -2250,7 +2408,9 @@ func migrationsFromLegacyFilePaths(config *Config) ([]FileMigration, error) {
 
 		files, err := ioutil.ReadDir(config.MigrateObfuscatedServerListDownloadDirectory)
 		if err != nil {
-			NoticeWarning("Migration: failed to read OSL download directory with error %s", StripFilePathsError(err, config.MigrateObfuscatedServerListDownloadDirectory))
+			NoticeWarning(
+				"Migration: failed to read OSL download directory with error %s",
+				common.RedactFilePathsError(err, config.MigrateObfuscatedServerListDownloadDirectory))
 		} else {
 			for _, file := range files {
 				if oslFileRegex.MatchString(file.Name()) {
@@ -2285,7 +2445,9 @@ func migrationsFromLegacyFilePaths(config *Config) ([]FileMigration, error) {
 
 		files, err := ioutil.ReadDir(upgradeDownloadDir)
 		if err != nil {
-			NoticeWarning("Migration: failed to read upgrade download directory with error %s", StripFilePathsError(err, upgradeDownloadDir))
+			NoticeWarning(
+				"Migration: failed to read upgrade download directory with error %s",
+				common.RedactFilePathsError(err, upgradeDownloadDir))
 		} else {
 
 			for _, file := range files {

+ 39 - 16
psiphon/controller.go

@@ -38,6 +38,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 	lrucache "github.com/cognusion/go-cache-lru"
 )
@@ -85,6 +86,7 @@ type Controller struct {
 	packetTunnelClient                      *tun.Client
 	packetTunnelTransport                   *PacketTunnelTransport
 	staggerMutex                            sync.Mutex
+	resolver                                *resolver.Resolver
 }
 
 // NewController initializes a new controller.
@@ -101,15 +103,6 @@ func NewController(config *Config) (controller *Controller, err error) {
 	// tunnels established by the controller.
 	NoticeSessionId(config.SessionID)
 
-	untunneledDialConfig := &DialConfig{
-		UpstreamProxyURL:              config.UpstreamProxyURL,
-		CustomHeaders:                 config.CustomHeaders,
-		DeviceBinder:                  config.deviceBinder,
-		DnsServerGetter:               config.DnsServerGetter,
-		IPv6Synthesizer:               config.IPv6Synthesizer,
-		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
-	}
-
 	// Attempt to apply any valid, local stored tactics. The pre-done context
 	// ensures no tactics request is attempted now.
 	doneContext, cancelFunc := context.WithCancel(context.Background())
@@ -127,13 +120,12 @@ func NewController(config *Config) (controller *Controller, err error) {
 		runWaitGroup: new(sync.WaitGroup),
 		// connectedTunnels and failedTunnels buffer sizes are large enough to
 		// receive full pools of tunnels without blocking. Senders should not block.
-		connectedTunnels:     make(chan *Tunnel, MAX_TUNNEL_POOL_SIZE),
-		failedTunnels:        make(chan *Tunnel, MAX_TUNNEL_POOL_SIZE),
-		tunnelPoolSize:       TUNNEL_POOL_SIZE,
-		tunnels:              make([]*Tunnel, 0),
-		establishedOnce:      false,
-		isEstablishing:       false,
-		untunneledDialConfig: untunneledDialConfig,
+		connectedTunnels: make(chan *Tunnel, MAX_TUNNEL_POOL_SIZE),
+		failedTunnels:    make(chan *Tunnel, MAX_TUNNEL_POOL_SIZE),
+		tunnelPoolSize:   TUNNEL_POOL_SIZE,
+		tunnels:          make([]*Tunnel, 0),
+		establishedOnce:  false,
+		isEstablishing:   false,
 
 		untunneledSplitTunnelClassifications: lrucache.NewWithLRU(
 			splitTunnelClassificationTTL,
@@ -159,6 +151,24 @@ func NewController(config *Config) (controller *Controller, err error) {
 		signalRestartEstablishing: make(chan struct{}, 1),
 	}
 
+	// Initialize untunneledDialConfig, used by untunneled dials including
+	// remote server list and upgrade downloads.
+	controller.untunneledDialConfig = &DialConfig{
+		UpstreamProxyURL: controller.config.UpstreamProxyURL,
+		CustomHeaders:    controller.config.CustomHeaders,
+		DeviceBinder:     controller.config.deviceBinder,
+		IPv6Synthesizer:  controller.config.IPv6Synthesizer,
+		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
+			IPs, err := UntunneledResolveIP(
+				ctx, controller.config, controller.resolver, hostname)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			return IPs, nil
+		},
+		TrustedCACertificatesFilename: controller.config.TrustedCACertificatesFilename,
+	}
+
 	if config.PacketTunnelTunFileDescriptor > 0 {
 
 		// Run a packet tunnel client. The lifetime of the tun.Client is the
@@ -208,6 +218,16 @@ func (controller *Controller) Run(ctx context.Context) {
 
 	// Start components
 
+	// Initialize a single resolver to be used by all dials. Sharing a single
+	// resolver ensures cached results are shared, and that network state
+	// query overhead is amortized over all dials. Multiple dials can resolve
+	// domain concurrently.
+	//
+	// config.SetResolver makes this resolver available to MakeDialParameters.
+	controller.resolver = NewResolver(controller.config, true)
+	defer controller.resolver.Stop()
+	controller.config.SetResolver(controller.resolver)
+
 	// TODO: IPv6 support
 	var listenIP string
 	if controller.config.ListenInterface == "" {
@@ -1801,6 +1821,9 @@ func (controller *Controller) stopEstablishing() {
 	// the bulk of all datastore transactions: iterating over server entries,
 	// storing new server entries, etc.
 	emitDatastoreMetrics()
+
+	// Similarly, establishment generates the bulk of domain resolves.
+	emitDNSMetrics(controller.resolver)
 }
 
 // establishCandidateGenerator populates the candidate queue with server entries

+ 47 - 3
psiphon/dialParameters.go

@@ -21,6 +21,7 @@ package psiphon
 
 import (
 	"bytes"
+	"context"
 	"crypto/md5"
 	"encoding/binary"
 	"net"
@@ -36,6 +37,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	utls "github.com/refraction-networking/utls"
 	regen "github.com/zach-klippenstein/goregen"
@@ -137,8 +139,11 @@ type DialParameters struct {
 
 	DialDuration time.Duration `json:"-"`
 
-	dialConfig *DialConfig
-	meekConfig *MeekConfig
+	resolver          *resolver.Resolver `json:"-"`
+	ResolveParameters *resolver.ResolveParameters
+
+	dialConfig *DialConfig `json:"-"`
+	meekConfig *MeekConfig `json:"-"`
 }
 
 // MakeDialParameters creates a new DialParameters for the candidate server
@@ -189,6 +194,7 @@ func MakeDialParameters(
 	replayUserAgent := p.Bool(parameters.ReplayUserAgent)
 	replayAPIRequestPadding := p.Bool(parameters.ReplayAPIRequestPadding)
 	replayHoldOffTunnel := p.Bool(parameters.ReplayHoldOffTunnel)
+	replayResolveParameters := p.Bool(parameters.ReplayResolveParameters)
 
 	// Check for existing dial parameters for this server/network ID.
 
@@ -287,6 +293,12 @@ func MakeDialParameters(
 		dialParams = &DialParameters{}
 	}
 
+	// Point to the current resolver to be used in dials.
+	dialParams.resolver = config.GetResolver()
+	if dialParams.resolver == nil {
+		return nil, errors.TraceNew("missing resolver")
+	}
+
 	if isExchanged {
 		// Set isReplay to false to cause all non-exchanged values to be
 		// initialized; this also causes the exchange case to not log as replay.
@@ -709,6 +721,18 @@ func MakeDialParameters(
 		}
 	}
 
+	useResolver := protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) ||
+		dialParams.ConjureAPIRegistration
+
+	if (!isReplay || !replayResolveParameters) && useResolver {
+
+		dialParams.ResolveParameters, err = dialParams.resolver.MakeResolveParameters(
+			p, dialParams.FrontingProviderID)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
+
 	if !isReplay || !replayHoldOffTunnel {
 
 		if common.Contains(
@@ -860,14 +884,34 @@ func MakeDialParameters(
 
 	// Initialize Dial/MeekConfigs to be passed to the corresponding dialers.
 
+	// Custom ResolveParameters are set only when useResolver is true, but
+	// DialConfig.ResolveIP is wired up unconditionally, so that we fail over
+	// to resolving, but without custom parameters, in case of a
+	// misconfigured or miscoded case.
+	//
+	// ResolveIP will use the networkID obtained above, as it will be used
+	// almost immediately, instead of incurring the overhead of calling
+	// GetNetworkID again.
+	resolveIP := func(ctx context.Context, hostname string) ([]net.IP, error) {
+		IPs, err := dialParams.resolver.ResolveIP(
+			ctx,
+			networkID,
+			dialParams.ResolveParameters,
+			hostname)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		return IPs, nil
+	}
+
 	dialParams.dialConfig = &DialConfig{
 		DiagnosticID:                  serverEntry.GetDiagnosticID(),
 		UpstreamProxyURL:              config.UpstreamProxyURL,
 		CustomHeaders:                 dialCustomHeaders,
 		BPFProgramInstructions:        dialParams.BPFProgramInstructions,
 		DeviceBinder:                  config.deviceBinder,
-		DnsServerGetter:               config.DnsServerGetter,
 		IPv6Synthesizer:               config.IPv6Synthesizer,
+		ResolveIP:                     resolveIP,
 		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
 		FragmentorConfig:              fragmentor.NewUpstreamConfig(p, dialParams.TunnelProtocol, dialParams.FragmentorSeed),
 		UpstreamProxyErrorCallback:    upstreamProxyErrorCallback,

+ 14 - 1
psiphon/dialParameters_test.go

@@ -25,6 +25,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"os"
+	"reflect"
 	"testing"
 	"time"
 
@@ -87,11 +88,16 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	applyParameters[parameters.HoldOffTunnelProtocols] = holdOffTunnelProtocols
 	applyParameters[parameters.HoldOffTunnelFrontingProviderIDs] = []string{frontingProviderID}
 	applyParameters[parameters.HoldOffTunnelProbability] = 1.0
+	applyParameters[parameters.DNSResolverAlternateServers] = []string{"127.0.0.1", "127.0.0.2", "127.0.0.3"}
 	err = clientConfig.SetParameters("tag1", false, applyParameters)
 	if err != nil {
 		t.Fatalf("SetParameters failed: %s", err)
 	}
 
+	resolver := NewResolver(clientConfig, true)
+	defer resolver.Stop()
+	clientConfig.SetResolver(resolver)
+
 	err = OpenDataStore(clientConfig)
 	if err != nil {
 		t.Fatalf("error initializing client datastore: %s", err)
@@ -171,7 +177,8 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 
 	if protocol.TunnelProtocolUsesFrontedMeek(tunnelProtocol) &&
 		(dialParams.MeekFrontingDialAddress == "" ||
-			dialParams.MeekFrontingHost == "") {
+			dialParams.MeekFrontingHost == "" ||
+			dialParams.ResolveParameters == nil) {
 		t.Fatalf("missing meek fronting fields")
 	}
 
@@ -343,6 +350,12 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 		t.Fatalf("mismatching API request fields")
 	}
 
+	if (replayDialParams.ResolveParameters == nil) != (dialParams.ResolveParameters == nil) ||
+		(replayDialParams.ResolveParameters != nil &&
+			!reflect.DeepEqual(replayDialParams.ResolveParameters, dialParams.ResolveParameters)) {
+		t.Fatalf("mismatching ResolveParameters fields")
+	}
+
 	// Test: no replay after change tactics
 
 	applyParameters[parameters.ReplayDialParametersTTL] = "1s"

+ 4 - 0
psiphon/exchange_test.go

@@ -88,6 +88,10 @@ func TestServerEntryExchange(t *testing.T) {
 		t.Fatalf("Commit failed: %s", err)
 	}
 
+	resolver := NewResolver(config, true)
+	defer resolver.Stop()
+	config.SetResolver(resolver)
+
 	err = OpenDataStore(config)
 	if err != nil {
 		t.Fatalf("OpenDataStore failed: %s", err)

+ 25 - 5
psiphon/feedback.go

@@ -33,6 +33,7 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"net"
 	"net/http"
 	"net/url"
 	"path"
@@ -104,6 +105,16 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 		return errors.TraceNew("error diagnostics empty")
 	}
 
+	// Initialize a resolver to use for dials. useBindToDevice is false so
+	// that the feedback upload will be tunneled, indirectly, if it routes
+	// through the VPN.
+	//
+	// config.SetResolver makes this resolver available to MakeDialParameters
+	// in GetTactics.
+	resolver := NewResolver(config, false)
+	defer resolver.Stop()
+	config.SetResolver(resolver)
+
 	// Get tactics, may update client parameters
 	p := config.GetParameters().Get()
 	timeout := p.Duration(parameters.FeedbackTacticsWaitPeriod)
@@ -123,12 +134,21 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 	transferURLs := p.TransferURLs(parameters.FeedbackUploadURLs)
 	p.Close()
 
+	// Initialize the feedback upload dial configuration. config.DeviceBinder
+	// is not applied; see resolver comment above.
 	untunneledDialConfig := &DialConfig{
-		UpstreamProxyURL:              config.UpstreamProxyURL,
-		CustomHeaders:                 config.CustomHeaders,
-		DeviceBinder:                  nil,
-		IPv6Synthesizer:               nil,
-		DnsServerGetter:               nil,
+		UpstreamProxyURL: config.UpstreamProxyURL,
+		CustomHeaders:    config.CustomHeaders,
+		DeviceBinder:     nil,
+		IPv6Synthesizer:  config.IPv6Synthesizer,
+		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
+			IPs, err := UntunneledResolveIP(
+				ctx, config, resolver, hostname)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			return IPs, nil
+		},
 		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
 	}
 

+ 3 - 3
psiphon/httpProxy.go

@@ -312,7 +312,7 @@ func (proxy *HttpProxy) urlProxyHandler(responseWriter http.ResponseWriter, requ
 		err = std_errors.New("missing origin URL")
 	}
 	if err != nil {
-		NoticeWarning("%s", errors.Trace(FilterUrlError(err)))
+		NoticeWarning("%s", errors.Trace(common.RedactURLError(err)))
 		forceClose(responseWriter)
 		return
 	}
@@ -320,7 +320,7 @@ func (proxy *HttpProxy) urlProxyHandler(responseWriter http.ResponseWriter, requ
 	// Origin URL must be well-formed, absolute, and have a scheme of "http" or "https"
 	originURL, err := common.SafeParseRequestURI(originURLString)
 	if err != nil {
-		NoticeWarning("%s", errors.Trace(FilterUrlError(err)))
+		NoticeWarning("%s", errors.Trace(common.RedactURLError(err)))
 		forceClose(responseWriter)
 		return
 	}
@@ -499,7 +499,7 @@ func (proxy *HttpProxy) relayHTTPRequest(
 	}
 
 	if err != nil {
-		NoticeWarning("%s", errors.Trace(FilterUrlError(err)))
+		NoticeWarning("%s", errors.Trace(common.RedactURLError(err)))
 		forceClose(responseWriter)
 		return
 	}

+ 9 - 2
psiphon/interrupt_dials_test.go

@@ -36,15 +36,20 @@ import (
 
 func TestInterruptDials(t *testing.T) {
 
+	resolveIP := func(_ context.Context, host string) ([]net.IP, error) {
+		return []net.IP{net.ParseIP(host)}, nil
+	}
+
 	makeDialers := make(map[string]func(string) common.Dialer)
 
 	makeDialers["TCP"] = func(string) common.Dialer {
-		return NewTCPDialer(&DialConfig{})
+		return NewTCPDialer(&DialConfig{ResolveIP: resolveIP})
 	}
 
 	makeDialers["SOCKS4-Proxied"] = func(mockServerAddr string) common.Dialer {
 		return NewTCPDialer(
 			&DialConfig{
+				ResolveIP:        resolveIP,
 				UpstreamProxyURL: "socks4a://" + mockServerAddr,
 			})
 	}
@@ -52,6 +57,7 @@ func TestInterruptDials(t *testing.T) {
 	makeDialers["SOCKS5-Proxied"] = func(mockServerAddr string) common.Dialer {
 		return NewTCPDialer(
 			&DialConfig{
+				ResolveIP:        resolveIP,
 				UpstreamProxyURL: "socks5://" + mockServerAddr,
 			})
 	}
@@ -59,6 +65,7 @@ func TestInterruptDials(t *testing.T) {
 	makeDialers["HTTP-CONNECT-Proxied"] = func(mockServerAddr string) common.Dialer {
 		return NewTCPDialer(
 			&DialConfig{
+				ResolveIP:        resolveIP,
 				UpstreamProxyURL: "http://" + mockServerAddr,
 			})
 	}
@@ -79,7 +86,7 @@ func TestInterruptDials(t *testing.T) {
 		return NewCustomTLSDialer(
 			&CustomTLSConfig{
 				Parameters:               params,
-				Dial:                     NewTCPDialer(&DialConfig{}),
+				Dial:                     NewTCPDialer(&DialConfig{ResolveIP: resolveIP}),
 				RandomizedTLSProfileSeed: seed,
 			})
 	}

+ 81 - 50
psiphon/net.go

@@ -38,12 +38,10 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
-	"github.com/miekg/dns"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"golang.org/x/net/bpf"
 )
 
-const DNS_PORT = 53
-
 // DialConfig contains parameters to determine the behavior
 // of a Psiphon dialer (TCPDial, UDPDial, MeekDial, etc.)
 type DialConfig struct {
@@ -73,21 +71,17 @@ type DialConfig struct {
 	// socket before connecting.
 	BPFProgramInstructions []bpf.RawInstruction
 
-	// BindToDevice parameters are used to exclude connections and
-	// associated DNS requests from VPN routing.
-	// When DeviceBinder is set, any underlying socket is
-	// submitted to the device binding servicebefore connecting.
-	// The service should bind the socket to a device so that it doesn't route
-	// through a VPN interface. This service is also used to bind UDP sockets used
-	// for DNS requests, in which case DnsServerGetter is used to get the
-	// current active untunneled network DNS server.
-	DeviceBinder    DeviceBinder
-	DnsServerGetter DnsServerGetter
+	// DeviceBinder, when not nil, is applied when dialing UDP/TCP. See:
+	// DeviceBinder doc.
+	DeviceBinder DeviceBinder
+
+	// IPv6Synthesizer, when not nil, is applied when dialing UDP/TCP. See:
+	// IPv6Synthesizer doc.
 	IPv6Synthesizer IPv6Synthesizer
 
-	// TrustedCACertificatesFilename specifies a file containing trusted
-	// CA certs. See Config.TrustedCACertificatesFilename.
-	TrustedCACertificatesFilename string
+	// ResolveIP is used to resolve destination domains. ResolveIP should
+	// return either at least one IP address or an error.
+	ResolveIP func(context.Context, string) ([]net.IP, error)
 
 	// ResolvedIPCallback, when set, is called with the IP address that was
 	// dialed. This is either the specified IP address in the dial address,
@@ -96,6 +90,10 @@ type DialConfig struct {
 	// The callback may be invoked by a concurrent goroutine.
 	ResolvedIPCallback func(string)
 
+	// TrustedCACertificatesFilename specifies a file containing trusted
+	// CA certs. See Config.TrustedCACertificatesFilename.
+	TrustedCACertificatesFilename string
+
 	// FragmentorConfig specifies whether to layer a fragmentor.Conn on top
 	// of dialed TCP conns, and the fragmentation configuration to use.
 	FragmentorConfig *fragmentor.Config
@@ -142,12 +140,11 @@ type DeviceBinder interface {
 	BindToDevice(fileDescriptor int) (string, error)
 }
 
-// DnsServerGetter defines the interface to the external GetDnsServer provider
+// DNSServerGetter defines the interface to the external GetDNSServers provider
 // which calls into the host application to discover the native network DNS
 // server settings.
-type DnsServerGetter interface {
-	GetPrimaryDnsServer() string
-	GetSecondaryDnsServer() string
+type DNSServerGetter interface {
+	GetDNSServers() []string
 }
 
 // IPv6Synthesizer defines the interface to the external IPv6Synthesize
@@ -158,6 +155,14 @@ type IPv6Synthesizer interface {
 	IPv6Synthesize(IPv4Addr string) string
 }
 
+// HasIPv6RouteGetter defines the interface to the external HasIPv6Route
+// provider which calls into the host application to determine if the host
+// has an IPv6 route.
+type HasIPv6RouteGetter interface {
+	// TODO: change to bool return value once gobind supports that type
+	HasIPv6Route() int
+}
+
 // NetworkIDGetter defines the interface to the external GetNetworkID
 // provider, which returns an identifier for the host's current active
 // network.
@@ -315,39 +320,65 @@ func WaitForNetworkConnectivity(
 	}
 }
 
-// ResolveIP uses a custom dns stack to make a DNS query over the
-// given TCP or UDP conn. This is used, e.g., when we need to ensure
-// that a DNS connection bypasses a VPN interface (BindToDevice) or
-// when we need to ensure that a DNS connection is tunneled.
-// Caller must set timeouts or interruptibility as required for conn.
-func ResolveIP(host string, conn net.Conn) (addrs []net.IP, ttls []time.Duration, err error) {
-
-	// Send the DNS query
-	dnsConn := &dns.Conn{Conn: conn}
-	defer dnsConn.Close()
-	query := new(dns.Msg)
-	query.SetQuestion(dns.Fqdn(host), dns.TypeA)
-	query.RecursionDesired = true
-	dnsConn.WriteMsg(query)
-
-	// Process the response
-	response, err := dnsConn.ReadMsg()
-	if err == nil && response.MsgHdr.Id != query.MsgHdr.Id {
-		err = dns.ErrId
+// New Resolver creates a new resolver using the specified config.
+// useBindToDevice indicates whether to apply config.BindToDevice, when it
+// exists; set useBindToDevice to false when the resolve doesn't need to be
+// excluded from any VPN routing.
+func NewResolver(config *Config, useBindToDevice bool) *resolver.Resolver {
+
+	networkConfig := &resolver.NetworkConfig{
+		LogWarning:   func(err error) { NoticeWarning("ResolveIP: %v", err) },
+		LogHostnames: config.EmitDiagnosticNetworkParameters,
 	}
-	if err != nil {
-		return nil, nil, errors.Trace(err)
-	}
-	addrs = make([]net.IP, 0)
-	ttls = make([]time.Duration, 0)
-	for _, answer := range response.Answer {
-		if a, ok := answer.(*dns.A); ok {
-			addrs = append(addrs, a.A)
-			ttl := time.Duration(a.Hdr.Ttl) * time.Second
-			ttls = append(ttls, ttl)
+
+	if config.DNSServerGetter != nil {
+		networkConfig.GetDNSServers = config.DNSServerGetter.GetDNSServers
+	}
+
+	if useBindToDevice && config.DeviceBinder != nil {
+		networkConfig.BindToDevice = config.DeviceBinder.BindToDevice
+	}
+
+	if config.IPv6Synthesizer != nil {
+		networkConfig.IPv6Synthesize = config.IPv6Synthesizer.IPv6Synthesize
+	}
+
+	if config.HasIPv6RouteGetter != nil {
+		networkConfig.HasIPv6Route = func() bool {
+			return config.HasIPv6RouteGetter.HasIPv6Route() == 1
 		}
 	}
-	return addrs, ttls, nil
+
+	return resolver.NewResolver(networkConfig, config.GetNetworkID())
+}
+
+// UntunneledResolveIP is used to resolve domains for untunneled dials,
+// including remote server list and upgrade downloads.
+func UntunneledResolveIP(
+	ctx context.Context,
+	config *Config,
+	resolver *resolver.Resolver,
+	hostname string) ([]net.IP, error) {
+
+	// Limitations: for untunneled resolves, there is currently no resolve
+	// parameter replay, and no support for pre-resolved IPs.
+
+	params, err := resolver.MakeResolveParameters(
+		config.GetParameters().Get(), "")
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	IPs, err := resolver.ResolveIP(
+		ctx,
+		config.GetNetworkID(),
+		params,
+		hostname)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return IPs, nil
 }
 
 // MakeUntunneledHTTPClient returns a net/http.Client which is configured to

+ 40 - 12
psiphon/notice.go

@@ -251,11 +251,11 @@ func (nl *noticeLogger) outputNotice(noticeType string, noticeFlags uint32, args
 		// Ensure direct server IPs are not exposed in notices. The "net" package,
 		// and possibly other 3rd party packages, will include destination addresses
 		// in I/O error messages.
-		output = StripIPAddresses(output)
+		output = common.RedactIPAddresses(output)
 	}
 
-	// Don't call StripFilePaths here, as the file path redaction can
-	// potentially match many non-path strings. Instead, StripFilePaths should
+	// Don't call RedactFilePaths here, as the file path redaction can
+	// potentially match many non-path strings. Instead, RedactFilePaths should
 	// be applied in specific cases.
 
 	nl.mutex.Lock()
@@ -446,7 +446,7 @@ func NoticeAvailableEgressRegions(regions []string) {
 		"AvailableEgressRegions", 0, "regions", sortedRegions)
 }
 
-func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
+func noticeWithDialParameters(noticeType string, dialParams *DialParameters, postDial bool) {
 
 	args := []interface{}{
 		"diagnosticID", dialParams.ServerEntry.GetDiagnosticID(),
@@ -463,7 +463,7 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
 		// Omit appliedTacticsTag as that is emitted in another notice.
 
 		if dialParams.BPFProgramName != "" {
-			args = append(args, "client_bpf", dialParams.BPFProgramName)
+			args = append(args, "clientBPF", dialParams.BPFProgramName)
 		}
 
 		if dialParams.SelectedSSHClientVersion {
@@ -486,9 +486,12 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
 			args = append(args, "meekDialAddress", dialParams.MeekDialAddress)
 		}
 
-		meekResolvedIPAddress := dialParams.MeekResolvedIPAddress.Load().(string)
-		if meekResolvedIPAddress != "" {
-			args = append(args, "meekResolvedIPAddress", meekResolvedIPAddress)
+		if protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) {
+			meekResolvedIPAddress := dialParams.MeekResolvedIPAddress.Load().(string)
+			if meekResolvedIPAddress != "" {
+				nonredacted := common.EscapeRedactIPAddressString(meekResolvedIPAddress)
+				args = append(args, "meekResolvedIPAddress", nonredacted)
+			}
 		}
 
 		if dialParams.MeekSNIServerName != "" {
@@ -553,6 +556,31 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
 			args = append(args, "conjureTransport", dialParams.ConjureTransport)
 		}
 
+		if dialParams.ResolveParameters != nil {
+
+			if dialParams.ResolveParameters.PreresolvedIPAddress != "" {
+				nonredacted := common.EscapeRedactIPAddressString(dialParams.ResolveParameters.PreresolvedIPAddress)
+				args = append(args, "DNSPreresolved", nonredacted)
+
+			} else {
+
+				// See dialParams.ResolveParameters comment in getBaseAPIParameters.
+
+				if dialParams.ResolveParameters.PreferAlternateDNSServer {
+					nonredacted := common.EscapeRedactIPAddressString(dialParams.ResolveParameters.AlternateDNSServer)
+					args = append(args, "DNSPreferred", nonredacted)
+				}
+
+				if dialParams.ResolveParameters.ProtocolTransformName != "" {
+					args = append(args, "DNSTransform", dialParams.ResolveParameters.ProtocolTransformName)
+				}
+
+				if postDial {
+					args = append(args, "DNSAttempt", dialParams.ResolveParameters.GetFirstAttemptWithAnswer())
+				}
+			}
+		}
+
 		if dialParams.DialConnMetrics != nil {
 			metrics := dialParams.DialConnMetrics.GetMetrics()
 			for name, value := range metrics {
@@ -575,22 +603,22 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
 
 // NoticeConnectingServer reports parameters and details for a single connection attempt
 func NoticeConnectingServer(dialParams *DialParameters) {
-	noticeWithDialParameters("ConnectingServer", dialParams)
+	noticeWithDialParameters("ConnectingServer", dialParams, false)
 }
 
 // NoticeConnectedServer reports parameters and details for a single successful connection
 func NoticeConnectedServer(dialParams *DialParameters) {
-	noticeWithDialParameters("ConnectedServer", dialParams)
+	noticeWithDialParameters("ConnectedServer", dialParams, true)
 }
 
 // NoticeRequestingTactics reports parameters and details for a tactics request attempt
 func NoticeRequestingTactics(dialParams *DialParameters) {
-	noticeWithDialParameters("RequestingTactics", dialParams)
+	noticeWithDialParameters("RequestingTactics", dialParams, false)
 }
 
 // NoticeRequestedTactics reports parameters and details for a successful tactics request
 func NoticeRequestedTactics(dialParams *DialParameters) {
-	noticeWithDialParameters("RequestedTactics", dialParams)
+	noticeWithDialParameters("RequestedTactics", dialParams, true)
 }
 
 // NoticeActiveTunnel is a successful connection that is used as an active tunnel for port forwarding

+ 3 - 0
psiphon/server/api.go

@@ -921,6 +921,9 @@ var baseDialParams = []requestParamSpec{
 	{"conjure_transport", isAnyString, requestParamOptional},
 	{"split_tunnel", isBooleanFlag, requestParamOptional | requestParamLogFlagAsBool},
 	{"split_tunnel_regions", isRegionCode, requestParamOptional | requestParamArray},
+	{"dns_preresolved", isAnyString, requestParamOptional},
+	{"dns_preferred", isAnyString, requestParamOptional},
+	{"dns_attempt", isIntString, requestParamOptional | requestParamLogStringAsInt},
 }
 
 // baseSessionAndDialParams adds baseDialParams to baseSessionParams.

+ 8 - 1
psiphon/server/meek_test.go

@@ -317,6 +317,9 @@ func TestMeekResiliency(t *testing.T) {
 
 	dialConfig := &psiphon.DialConfig{
 		DeviceBinder: new(fileDescriptorInterruptor),
+		ResolveIP: func(_ context.Context, host string) ([]net.IP, error) {
+			return []net.IP{net.ParseIP(host)}, nil
+		},
 	}
 
 	params, err := parameters.NewParameters(nil)
@@ -581,7 +584,11 @@ func runTestMeekAccessControl(t *testing.T, rateLimit, restrictProvider bool) {
 
 	for i := 0; i < attempts; i++ {
 
-		dialConfig := &psiphon.DialConfig{}
+		dialConfig := &psiphon.DialConfig{
+			ResolveIP: func(_ context.Context, host string) ([]net.IP, error) {
+				return []net.IP{net.ParseIP(host)}, nil
+			},
+		}
 
 		params, err := parameters.NewParameters(nil)
 		if err != nil {

+ 38 - 5
psiphon/server/server_test.go

@@ -23,7 +23,7 @@ import (
 	"context"
 	"encoding/base64"
 	"encoding/json"
-	"errors"
+	std_errors "errors"
 	"flag"
 	"fmt"
 	"io/ioutil"
@@ -44,12 +44,14 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/accesscontrol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
+	"github.com/miekg/dns"
 	"golang.org/x/net/proxy"
 )
 
@@ -2278,15 +2280,15 @@ func makeTunneledNTPRequestAttempt(
 	clientUDPConn.SetReadDeadline(time.Now().Add(timeout))
 	clientUDPConn.SetWriteDeadline(time.Now().Add(timeout))
 
-	addrs, _, err := psiphon.ResolveIP(testHostname, clientUDPConn)
+	addrs, err := resolveIP(testHostname, clientUDPConn)
 
 	clientUDPConn.Close()
 
 	if err == nil && (len(addrs) == 0 || len(addrs[0]) < 4) {
-		err = errors.New("no address")
+		err = std_errors.New("no address")
 	}
 	if err != nil {
-		return fmt.Errorf("ResolveIP failed: %s", err)
+		return fmt.Errorf("resolveIP failed: %s", err)
 	}
 
 	waitGroup.Wait()
@@ -2354,6 +2356,33 @@ func makeTunneledNTPRequestAttempt(
 	return nil
 }
 
+func resolveIP(host string, conn net.Conn) (addrs []net.IP, err error) {
+
+	// Send the DNS query (A record only)
+	dnsConn := &dns.Conn{Conn: conn}
+	defer dnsConn.Close()
+	query := new(dns.Msg)
+	query.SetQuestion(dns.Fqdn(host), dns.TypeA)
+	query.RecursionDesired = true
+	dnsConn.WriteMsg(query)
+
+	// Process the response
+	response, err := dnsConn.ReadMsg()
+	if err == nil && response.MsgHdr.Id != query.MsgHdr.Id {
+		err = dns.ErrId
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	addrs = make([]net.IP, 0)
+	for _, answer := range response.Answer {
+		if a, ok := answer.(*dns.A); ok {
+			addrs = append(addrs, a.A)
+		}
+	}
+	return addrs, nil
+}
+
 func pavePsinetDatabaseFile(
 	t *testing.T,
 	psinetFilename string,
@@ -2910,6 +2939,10 @@ func storePruneServerEntriesTest(
 		t.Fatalf("Commit failed: %s", err)
 	}
 
+	resolver := psiphon.NewResolver(clientConfig, true)
+	defer resolver.Stop()
+	clientConfig.SetResolver(resolver)
+
 	applyParameters := make(map[string]interface{})
 	applyParameters[parameters.RecordFailedTunnelPersistentStatsProbability] = 1.0
 
@@ -2956,7 +2989,7 @@ func storePruneServerEntriesTest(
 		}
 
 		err = psiphon.RecordFailedTunnelStat(
-			clientConfig, dialParams, nil, 0, 0, errors.New("test error"))
+			clientConfig, dialParams, nil, 0, 0, std_errors.New("test error"))
 		if err != nil {
 			t.Fatalf("RecordFailedTunnelStat failed: %s", err)
 		}

+ 4 - 0
psiphon/server/sessionID_test.go

@@ -144,6 +144,10 @@ func TestDuplicateSessionID(t *testing.T) {
 		t.Fatalf("Commit failed: %s", err)
 	}
 
+	resolver := psiphon.NewResolver(clientConfig, true)
+	defer resolver.Stop()
+	clientConfig.SetResolver(resolver)
+
 	err = psiphon.OpenDataStore(clientConfig)
 	if err != nil {
 		t.Fatalf("OpenDataStore failed: %s", err)

+ 54 - 4
psiphon/serverApi.go

@@ -772,7 +772,7 @@ func RecordFailedTunnelStat(
 	// Ensure direct server IPs are not exposed in logs. The "net" package, and
 	// possibly other 3rd party packages, will include destination addresses in
 	// I/O error messages.
-	tunnelError := StripIPAddressesString(tunnelErr.Error())
+	tunnelError := common.RedactIPAddressesString(tunnelErr.Error())
 
 	params["tunnel_error"] = tunnelError
 
@@ -946,9 +946,11 @@ func getBaseAPIParameters(
 			params["meek_dial_address"] = dialParams.MeekDialAddress
 		}
 
-		meekResolvedIPAddress := dialParams.MeekResolvedIPAddress.Load().(string)
-		if meekResolvedIPAddress != "" {
-			params["meek_resolved_ip_address"] = meekResolvedIPAddress
+		if protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) {
+			meekResolvedIPAddress := dialParams.MeekResolvedIPAddress.Load().(string)
+			if meekResolvedIPAddress != "" {
+				params["meek_resolved_ip_address"] = meekResolvedIPAddress
+			}
 		}
 
 		if dialParams.MeekSNIServerName != "" {
@@ -1041,6 +1043,54 @@ func getBaseAPIParameters(
 			params["conjure_transport"] = dialParams.ConjureTransport
 		}
 
+		if dialParams.ResolveParameters != nil {
+
+			if dialParams.ResolveParameters.PreresolvedIPAddress != "" {
+				params["dns_preresolved"] = dialParams.ResolveParameters.PreresolvedIPAddress
+
+			} else {
+
+				// Log enough information to distinguish several successful or
+				// failed circumvention cases of interest, including preferring
+				// alternate servers and/or using DNS protocol transforms, and
+				// appropriate for both handshake and failed_tunnel logging:
+				//
+				// - The initial attempt made by Resolver.ResolveIP,
+				//   preferring an alternate DNS server and/or using a
+				//   protocol transform succeeds (dns_result = 0, the initial
+				//   attempt, 0, got the first result).
+				//
+				// - A second attempt may be used, still preferring an
+				//   alternate DNS server but no longer using the protocol
+				//   transform, which presumably failed (dns_result = 1, the
+				//   second attempt, 1, got the first result).
+				//
+				// - Subsequent attempts will use the system DNS server and no
+				//   protocol transforms (dns_result > 2).
+				//
+				// Due to the design of Resolver.ResolveIP, the notion
+				// of "success" is approximate; for example a successful
+				// response may arrive after a subsequent attempt succeeds,
+				// simply due to slow network conditions. It's also possible
+				// that, for a given attemp, only one of the two concurrent
+				// requests (A and AAAA) succeeded.
+				//
+				// Note that ResolveParameters.GetFirstAttemptWithAnswer
+				// semantics assume that dialParams.ResolveParameters wasn't
+				// used by or modified by any other dial.
+
+				if dialParams.ResolveParameters.PreferAlternateDNSServer {
+					params["dns_preferred"] = dialParams.ResolveParameters.AlternateDNSServer
+				}
+
+				if dialParams.ResolveParameters.ProtocolTransformName != "" {
+					params["dns_transform"] = dialParams.ResolveParameters.ProtocolTransformName
+				}
+
+				params["dns_attempt"] = dialParams.ResolveParameters.GetFirstAttemptWithAnswer()
+			}
+		}
+
 		if dialParams.DialConnMetrics != nil {
 			metrics := dialParams.DialConnMetrics.GetMetrics()
 			for name, value := range metrics {

+ 15 - 0
psiphon/tactics_test.go

@@ -23,10 +23,13 @@ import (
 	"context"
 	"encoding/json"
 	"io/ioutil"
+	"net"
 	"os"
 	"sync/atomic"
 	"testing"
 	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 )
 
 func TestStandAloneGetTactics(t *testing.T) {
@@ -64,6 +67,10 @@ func TestStandAloneGetTactics(t *testing.T) {
 		t.Fatalf("error committing configuration file: %s", err)
 	}
 
+	resolver := NewResolver(config, true)
+	defer resolver.Stop()
+	config.SetResolver(resolver)
+
 	gotTactics := int32(0)
 
 	SetNoticeWriter(NewNoticeReceiver(
@@ -87,6 +94,14 @@ func TestStandAloneGetTactics(t *testing.T) {
 	}
 
 	untunneledDialConfig := &DialConfig{
+		ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
+			IPs, err := UntunneledResolveIP(
+				ctx, config, resolver, hostname)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			return IPs, nil
+		},
 		UpstreamProxyURL: config.UpstreamProxyURL,
 	}
 

+ 7 - 138
psiphon/utils.go

@@ -25,13 +25,9 @@ import (
 	std_errors "errors"
 	"fmt"
 	"net"
-	"net/url"
 	"os"
-	"path/filepath"
-	"regexp"
 	"runtime"
 	"runtime/debug"
-	"strings"
 	"syscall"
 	"time"
 
@@ -40,6 +36,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/refraction"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/stacktrace"
 )
 
@@ -70,26 +67,6 @@ func DecodeCertificate(encodedCertificate string) (certificate *x509.Certificate
 	return certificate, nil
 }
 
-// FilterUrlError transforms an error, when it is a url.Error, removing
-// the URL value. This is to avoid logging private user data in cases
-// where the URL may be a user input value.
-// This function is used with errors returned by net/http and net/url,
-// which are (currently) of type url.Error. In particular, the round trip
-// function used by our HttpProxy, http.Client.Do, returns errors of type
-// url.Error, with the URL being the url sent from the user's tunneled
-// applications:
-// https://github.com/golang/go/blob/release-branch.go1.4/src/net/http/client.go#L394
-func FilterUrlError(err error) error {
-	if urlErr, ok := err.(*url.Error); ok {
-		err = &url.Error{
-			Op:  urlErr.Op,
-			URL: "",
-			Err: urlErr.Err,
-		}
-	}
-	return err
-}
-
 // TrimError removes the middle of over-long error message strings
 func TrimError(err error) error {
 	const MAX_LEN = 100
@@ -120,118 +97,6 @@ func IsAddressInUseError(err error) bool {
 	return false
 }
 
-var stripIPAddressAndPortRegex = regexp.MustCompile(
-	// IP address
-	`(` +
-		// IPv4
-		//
-		// An IPv4 address can also be represented as an unsigned integer, or with
-		// octal or with hex octet values, but we do not check for any of these
-		// uncommon representations as some may match non-IP values and we don't
-		// expect the "net" package, etc., to emit them.)
-
-		`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|` +
-
-		// IPv6
-		//
-		// Optional brackets for IPv6 with port
-		`\[?` +
-		`(` +
-		// Uncompressed IPv6; ensure there are 8 segments to avoid matching, e.g., a
-		// timestamp
-		`(([a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4})|` +
-		// Compressed IPv6
-		`([a-fA-F0-9:]*::[a-fA-F0-9:]+)|([a-fA-F0-9:]+::[a-fA-F0-9:]*)` +
-		`)` +
-		// Optional mapped/translated/embeded IPv4 suffix
-		`(.\d{1,3}\.\d{1,3}\.\d{1,3})?` +
-		`\]?` +
-		`)` +
-
-		// Optional port number
-		`(:\d+)?`)
-
-// StripIPAddresses returns a copy of the input with all IP addresses (and
-// optional ports) replaced by "[redacted]". This is intended to be used to
-// strip addresses from "net" package I/O error messages and otherwise avoid
-// inadvertently recording direct server IPs via error message logs; and, in
-// metrics, to reduce the error space due to superfluous source port data.
-//
-// StripIPAddresses uses a simple regex match which liberally matches IP
-// address-like patterns and will match invalid addresses; for example, it
-// will match port numbers greater than 65535. We err on the side of redaction
-// and are not as concerned, in this context, with false positive matches. If
-// a user configures an upstream proxy address with an invalid IP or port
-// value, we prefer to redact it.
-//
-// See the stripIPAddressAndPortRegex comment for some uncommon IP address
-// representations that are not matched.
-func StripIPAddresses(b []byte) []byte {
-	return stripIPAddressAndPortRegex.ReplaceAll(b, []byte("[redacted]"))
-}
-
-// StripIPAddressesString is StripIPAddresses for strings.
-func StripIPAddressesString(s string) string {
-	return stripIPAddressAndPortRegex.ReplaceAllString(s, "[redacted]")
-}
-
-var stripFilePathRegex = regexp.MustCompile(
-	// File path
-	`(` +
-		// Leading characters
-		`[^ ]*` +
-		// At least one path separator
-		`/` +
-		// Path component; take until next space
-		`[^ ]*` +
-		`)+`)
-
-// StripFilePaths returns a copy of the input with all file paths
-// replaced by "[redacted]". First any occurrences of the provided file paths
-// are replaced and then an attempt is made to replace any other file paths by
-// searching with a heuristic. The latter is a best effort attempt it is not
-// guaranteed that it will catch every file path.
-func StripFilePaths(s string, filePaths ...string) string {
-	for _, filePath := range filePaths {
-		s = strings.ReplaceAll(s, filePath, "[redacted]")
-	}
-	return stripFilePathRegex.ReplaceAllLiteralString(filepath.ToSlash(s), "[redacted]")
-}
-
-// StripFilePathsError is StripFilePaths for errors.
-func StripFilePathsError(err error, filePaths ...string) error {
-	return std_errors.New(StripFilePaths(err.Error(), filePaths...))
-}
-
-// RedactNetError removes network address information from a "net" package
-// error message. Addresses may be domains or IP addresses.
-//
-// Limitations: some non-address error context can be lost; this function
-// makes assumptions about how the Go "net" package error messages are
-// formatted and will fail to redact network addresses if this assumptions
-// become untrue.
-func RedactNetError(err error) error {
-
-	// Example "net" package error messages:
-	//
-	// - lookup <domain>: no such host
-	// - lookup <domain>: No address associated with hostname
-	// - dial tcp <address>: connectex: No connection could be made because the target machine actively refused it
-	// - write tcp <address>-><address>: write: connection refused
-
-	if err == nil {
-		return err
-	}
-
-	errstr := err.Error()
-	index := strings.Index(errstr, ": ")
-	if index == -1 {
-		return err
-	}
-
-	return std_errors.New("[redacted]" + errstr[index:])
-}
-
 // SyncFileWriter wraps a file and exposes an io.Writer. At predefined
 // steps, the file is synced (flushed to disk) while writing.
 type SyncFileWriter struct {
@@ -327,6 +192,10 @@ func emitDatastoreMetrics() {
 	NoticeInfo("Datastore metrics at %s: %s", stacktrace.GetParentFunctionName(), GetDataStoreMetrics())
 }
 
+func emitDNSMetrics(resolver *resolver.Resolver) {
+	NoticeInfo("DNS metrics at %s: %s", stacktrace.GetParentFunctionName(), resolver.GetMetrics())
+}
+
 func DoGarbageCollection() {
 	debug.SetGCPercent(5)
 	debug.FreeOSMemory()
@@ -381,7 +250,7 @@ func DoFileMigration(migration FileMigration) error {
 	}
 	info, err := os.Stat(migration.OldPath)
 	if err != nil {
-		return errors.Tracef(errPrefix+"error getting file info: %s", StripFilePathsError(err, migration.OldPath))
+		return errors.Tracef(errPrefix+"error getting file info: %s", common.RedactFilePathsError(err, migration.OldPath))
 	}
 	if info.IsDir() != migration.IsDir {
 		if migration.IsDir {
@@ -397,7 +266,7 @@ func DoFileMigration(migration FileMigration) error {
 
 	err = os.Rename(migration.OldPath, migration.NewPath)
 	if err != nil {
-		return errors.Tracef(errPrefix+"renaming file failed with error %s", StripFilePathsError(err, migration.OldPath, migration.NewPath))
+		return errors.Tracef(errPrefix+"renaming file failed with error %s", common.RedactFilePathsError(err, migration.OldPath, migration.NewPath))
 	}
 
 	return nil