Преглед изворни кода

Merge pull request #610 from mirokuratczyk/nwpathmonitor

Use NWPathMonitor for reachability info on iOS12+
Rod Hynes пре 3 година
родитељ
комит
69c8bbf085

+ 20 - 8
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel.xcodeproj/project.pbxproj

@@ -38,8 +38,11 @@
 		66BDB0681DC26CCC0079384C /* SBJson4Writer.m in Sources */ = {isa = PBXBuildFile; fileRef = 66BDB0591DC26CCC0079384C /* SBJson4Writer.m */; };
 		CE3D1DA523906003009A4AF6 /* Backups.h in Headers */ = {isa = PBXBuildFile; fileRef = CE3D1DA323906003009A4AF6 /* Backups.h */; };
 		CE3D1DA623906003009A4AF6 /* Backups.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3D1DA423906003009A4AF6 /* Backups.m */; };
-		CE4616BF2539493600D1243E /* Reachability+HasNetworkConnectivity.h in Headers */ = {isa = PBXBuildFile; fileRef = CE4616BD2539493600D1243E /* Reachability+HasNetworkConnectivity.h */; };
-		CE4616C02539493600D1243E /* Reachability+HasNetworkConnectivity.m in Sources */ = {isa = PBXBuildFile; fileRef = CE4616BE2539493600D1243E /* Reachability+HasNetworkConnectivity.m */; };
+		CE676F2525FA818200F7DB13 /* ReachabilityProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = CE676F2325FA818200F7DB13 /* ReachabilityProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; };
+		CE676F2A25FA82DA00F7DB13 /* DefaultRouteMonitor.h in Headers */ = {isa = PBXBuildFile; fileRef = CE676F2825FA82DA00F7DB13 /* DefaultRouteMonitor.h */; settings = {ATTRIBUTES = (Public, ); }; };
+		CE676F2B25FA82DA00F7DB13 /* DefaultRouteMonitor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE676F2925FA82DA00F7DB13 /* DefaultRouteMonitor.m */; };
+		CE676F2F25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = CE676F2D25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.h */; };
+		CE676F3025FA830C00F7DB13 /* Reachability+ReachabilityProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = CE676F2E25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.m */; };
 		CE9549F525C8AAEE00F9AF86 /* Redactor.h in Headers */ = {isa = PBXBuildFile; fileRef = CE9549F325C8AAEE00F9AF86 /* Redactor.h */; };
 		CE9549F625C8AAEE00F9AF86 /* Redactor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9549F425C8AAEE00F9AF86 /* Redactor.m */; };
 		CEC229FC24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h in Headers */ = {isa = PBXBuildFile; fileRef = CEC229FA24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h */; };
@@ -104,8 +107,11 @@
 		66BDB0591DC26CCC0079384C /* SBJson4Writer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SBJson4Writer.m; sourceTree = "<group>"; };
 		CE3D1DA323906003009A4AF6 /* Backups.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Backups.h; sourceTree = "<group>"; };
 		CE3D1DA423906003009A4AF6 /* Backups.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Backups.m; sourceTree = "<group>"; };
-		CE4616BD2539493600D1243E /* Reachability+HasNetworkConnectivity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Reachability+HasNetworkConnectivity.h"; sourceTree = "<group>"; };
-		CE4616BE2539493600D1243E /* Reachability+HasNetworkConnectivity.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Reachability+HasNetworkConnectivity.m"; sourceTree = "<group>"; };
+		CE676F2325FA818200F7DB13 /* ReachabilityProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ReachabilityProtocol.h; sourceTree = "<group>"; };
+		CE676F2825FA82DA00F7DB13 /* DefaultRouteMonitor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DefaultRouteMonitor.h; sourceTree = "<group>"; };
+		CE676F2925FA82DA00F7DB13 /* DefaultRouteMonitor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DefaultRouteMonitor.m; sourceTree = "<group>"; };
+		CE676F2D25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Reachability+ReachabilityProtocol.h"; sourceTree = "<group>"; };
+		CE676F2E25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Reachability+ReachabilityProtocol.m"; sourceTree = "<group>"; };
 		CE9549F325C8AAEE00F9AF86 /* Redactor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Redactor.h; sourceTree = "<group>"; };
 		CE9549F425C8AAEE00F9AF86 /* Redactor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Redactor.m; sourceTree = "<group>"; };
 		CEC229FA24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PsiphonProviderNoticeHandlerShim.h; sourceTree = "<group>"; };
@@ -158,8 +164,6 @@
 				CECF014F2538E14B00CD3E5C /* NetworkID.m */,
 				CEDBA51025B7737C007685E2 /* NetworkInterface.h */,
 				CEDBA51125B7737C007685E2 /* NetworkInterface.m */,
-				CE4616BD2539493600D1243E /* Reachability+HasNetworkConnectivity.h */,
-				CE4616BE2539493600D1243E /* Reachability+HasNetworkConnectivity.m */,
 			);
 			path = Network;
 			sourceTree = "<group>";
@@ -266,6 +270,11 @@
 			children = (
 				662659251DD270E900872F6C /* Reachability.h */,
 				662659261DD270E900872F6C /* Reachability.m */,
+				CE676F2325FA818200F7DB13 /* ReachabilityProtocol.h */,
+				CE676F2825FA82DA00F7DB13 /* DefaultRouteMonitor.h */,
+				CE676F2925FA82DA00F7DB13 /* DefaultRouteMonitor.m */,
+				CE676F2D25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.h */,
+				CE676F2E25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.m */,
 			);
 			path = Reachability;
 			sourceTree = "<group>";
@@ -303,7 +312,8 @@
 				CE3D1DA523906003009A4AF6 /* Backups.h in Headers */,
 				66BAD3351E525FBC00CD06DE /* JailbreakCheck.h in Headers */,
 				4E89F7FF1E2ED3CE00005F4C /* LookupIPv6.h in Headers */,
-				CE4616BF2539493600D1243E /* Reachability+HasNetworkConnectivity.h in Headers */,
+				CE676F2F25FA830C00F7DB13 /* Reachability+ReachabilityProtocol.h in Headers */,
+				CE676F2A25FA82DA00F7DB13 /* DefaultRouteMonitor.h in Headers */,
 				662659271DD270E900872F6C /* Reachability.h in Headers */,
 				66BDB05D1DC26CCC0079384C /* SBJson4StreamParser.h in Headers */,
 				52BE676825B8A615002DB553 /* PsiphonClientPlatform.h in Headers */,
@@ -322,6 +332,7 @@
 				66BDB0631DC26CCC0079384C /* SBJson4StreamWriter.h in Headers */,
 				CECF01442538D34100CD3E5C /* IPv6Synthesizer.h in Headers */,
 				66BDB0671DC26CCC0079384C /* SBJson4Writer.h in Headers */,
+				CE676F2525FA818200F7DB13 /* ReachabilityProtocol.h in Headers */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -454,12 +465,13 @@
 				4E89F7FE1E2ED3CE00005F4C /* LookupIPv6.c in Sources */,
 				CECF01512538E14B00CD3E5C /* NetworkID.m in Sources */,
 				66BAD3361E525FBC00CD06DE /* JailbreakCheck.m in Sources */,
+				CE676F2B25FA82DA00F7DB13 /* DefaultRouteMonitor.m in Sources */,
 				CEDBA51325B7737C007685E2 /* NetworkInterface.m in Sources */,
+				CE676F3025FA830C00F7DB13 /* Reachability+ReachabilityProtocol.m in Sources */,
 				66BDB0681DC26CCC0079384C /* SBJson4Writer.m in Sources */,
 				66BDB0621DC26CCC0079384C /* SBJson4StreamTokeniser.m in Sources */,
 				66BDB0441DA6C7DD0079384C /* PsiphonTunnel.m in Sources */,
 				CEC229FD24F047E700534D04 /* PsiphonProviderNoticeHandlerShim.m in Sources */,
-				CE4616C02539493600D1243E /* Reachability+HasNetworkConnectivity.m in Sources */,
 				CECF014A2538DD0B00CD3E5C /* PsiphonProviderNetwork.m in Sources */,
 				662659281DD270E900872F6C /* Reachability.m in Sources */,
 				66BDB0601DC26CCC0079384C /* SBJson4StreamParserState.m in Sources */,

+ 9 - 2
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/NetworkID.h

@@ -18,7 +18,7 @@
  */
 
 #import <Foundation/Foundation.h>
-#import "Reachability.h"
+#import "ReachabilityProtocol.h"
 
 NS_ASSUME_NONNULL_BEGIN
 
@@ -29,7 +29,14 @@ NS_ASSUME_NONNULL_BEGIN
 ///
 /// See network ID requirements here:
 /// https://godoc.org/github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon#NetworkIDGetter
-+ (NSString *)getNetworkID:(NetworkStatus)networkStatus;
+/// @param reachability ReachabilityProtocol implementer used to determine active interface on iOS >=12 when
+/// currentNetworkStatus is NetworkReachabilityReachableViaWired.
+/// @param currentNetworkStatus Used to determine network ID and, on iOS <12, to determine the active interface when
+/// currentNetworkStatus is NetworkReachabilityReachableViaWired.
+/// @param outWarn If non-nil, then a non-fatal error occurred while determining the network ID and a valid network ID will still be returned.
++ (NSString *)getNetworkIDWithReachability:(id<ReachabilityProtocol>)reachability
+                   andCurrentNetworkStatus:(NetworkReachability)currentNetworkStatus
+                                   warning:(NSError *_Nullable *_Nonnull)outWarn;
 
 @end
 

+ 42 - 5
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/NetworkID.m

@@ -18,25 +18,31 @@
  */
 
 #import "NetworkID.h"
+#import "NetworkInterface.h"
 #import <CoreTelephony/CTTelephonyNetworkInfo.h>
 #import <CoreTelephony/CTCarrier.h>
 #import <SystemConfiguration/CaptiveNetwork.h>
 
 @implementation NetworkID
 
-+ (NSString *)getNetworkID:(NetworkStatus)networkStatus {
+// See comment in header.
++ (NSString *)getNetworkIDWithReachability:(id<ReachabilityProtocol>)reachability
+                   andCurrentNetworkStatus:(NetworkReachability)currentNetworkStatus
+                                   warning:(NSError *_Nullable *_Nonnull)outWarn {
+
+    *outWarn = nil;
 
     NSMutableString *networkID = [NSMutableString stringWithString:@"UNKNOWN"];
-    if (networkStatus == ReachableViaWiFi) {
+    if (currentNetworkStatus == NetworkReachabilityReachableViaWiFi) {
         [networkID setString:@"WIFI"];
         NSArray *networkInterfaceNames = (__bridge_transfer id)CNCopySupportedInterfaces();
         for (NSString *networkInterfaceName in networkInterfaceNames) {
             NSDictionary *networkInterfaceInfo = (__bridge_transfer id)CNCopyCurrentNetworkInfo((__bridge CFStringRef)networkInterfaceName);
-            if (networkInterfaceInfo[@"BSSID"]) {
-                [networkID appendFormat:@"-%@", networkInterfaceInfo[@"BSSID"]];
+            if (networkInterfaceInfo[(__bridge NSString*)kCNNetworkInfoKeyBSSID]) {
+                [networkID appendFormat:@"-%@", networkInterfaceInfo[(__bridge NSString*)kCNNetworkInfoKeyBSSID]];
             }
         }
-    } else if (networkStatus == ReachableViaWWAN) {
+    } else if (currentNetworkStatus == NetworkReachabilityReachableViaCellular) {
         [networkID setString:@"MOBILE"];
         CTTelephonyNetworkInfo *telephonyNetworkinfo = [[CTTelephonyNetworkInfo alloc] init];
         CTCarrier *cellularProvider = [telephonyNetworkinfo subscriberCellularProvider];
@@ -45,6 +51,37 @@
             NSString *mnc = [cellularProvider mobileNetworkCode];
             [networkID appendFormat:@"-%@-%@", mcc, mnc];
         }
+    } else if (currentNetworkStatus == NetworkReachabilityReachableViaWired) {
+        [networkID setString:@"WIRED"];
+
+        NSError *err;
+        NSString *activeInterface =
+            [NetworkInterface getActiveInterfaceWithReachability:reachability
+                                         andCurrentNetworkStatus:currentNetworkStatus
+                                                           error:&err];
+        if (err != nil) {
+            NSString *localizedDescription = [NSString stringWithFormat:@"error getting active interface %@", err.localizedDescription];
+            *outWarn = [[NSError alloc] initWithDomain:@"iOSLibrary"
+                                                  code:1
+                                              userInfo:@{NSLocalizedDescriptionKey:localizedDescription}];
+            return networkID;
+        }
+
+        if (activeInterface != nil) {
+            NSError *err;
+            NSString *interfaceAddress = [NetworkInterface getInterfaceAddress:activeInterface
+                                                                         error:&err];
+            if (err != nil) {
+                NSString *localizedDescription =
+                    [NSString stringWithFormat:@"getNetworkID: error getting interface address %@", err.localizedDescription];
+                *outWarn = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: localizedDescription}];
+                return networkID;
+            } else if (interfaceAddress != nil) {
+                [networkID appendFormat:@"-%@", interfaceAddress];
+            }
+        }
+    } else if (currentNetworkStatus == NetworkReachabilityReachableViaLoopback) {
+        [networkID setString:@"LOOPBACK"];
     }
     return networkID;
 }

+ 17 - 24
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/NetworkInterface.h

@@ -19,37 +19,30 @@
 
 #import <Foundation/Foundation.h>
 #import <Network/path.h>
+#import "ReachabilityProtocol.h"
 
 NS_ASSUME_NONNULL_BEGIN
 
-/// NetworkPathState represents the state of the network path on the device.
-@interface NetworkPathState : NSObject
-
-/// Network path state.
-@property (nonatomic, nullable) nw_path_t path;
-
-/// Default active interface available to the network path.
-@property (nonatomic, nullable) nw_interface_t defaultActiveInterface;
-
-@end
-
 /// NetworkInterface provides a set of functions for discovering active network interfaces on the device.
 @interface NetworkInterface : NSObject
 
+/// Returns address assigned to the given interface. If the interface has no assigned addresses, or only has a link-local IPv6 address,
+/// then nil is returned.
+/// @param interfaceName Interface name. E.g. "en0".
+/// @param outError If non-nil, then an error occurred while trying determine the interface address.
++ (NSString*_Nullable)getInterfaceAddress:(NSString*_Nonnull)interfaceName
+                                    error:(NSError *_Nullable *_Nonnull)outError;
+
 /// Returns list of active interfaces excluding the loopback interface which support communicating with IPv4, or IPv6, addresses.
-+ (NSSet<NSString*>*_Nullable)activeInterfaces;
-
-/// Returns the currrent network path state and default active interface. The default active interface is found by mapping the active
-/// interface type used by the current network path to the first interface available to that path which is of the same type (e.g. WiFi, Cellular,
-/// etc.). This allows for the possibility of returning a non-default active interface in the scenario where there are other active interfaces
-/// which share the same type as the default active interface. This design limitation is present because querying the routing table is not
-/// supported on iOS; therefore we cannot query the routing table for the interface associated with the default route. Fortunately the
-/// selected interface should always be capable of routing traffic to the internet, even if a non-default active interface is chosen.
-/// @param activeInterfaces If non-nil, then only interfaces available to the current network path which are present in this list will
-/// be considered when searching for the default active interface. If nil, then all interfaces available to the current network path will be
-/// searched.
-/// @return The current network path state. See NetworkPathState for further details.
-+ (NetworkPathState*)networkPathState:(NSSet<NSString*>*_Nullable)activeInterfaces API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
++ (NSSet<NSString*>*)activeInterfaces:(NSError *_Nullable *_Nonnull)outError;
+
+/// Returns the active interface name.
+/// @param reachability ReachabilityProtocol implementer used to determine active interface on iOS >=12.
+/// @param currentNetworkStatus Used to determine active interface on iOS <12.
+/// @param outError If non-nil, then an error occurred while determining the active interface.
++ (NSString*)getActiveInterfaceWithReachability:(id<ReachabilityProtocol>)reachability
+                        andCurrentNetworkStatus:(NetworkReachability)currentNetworkStatus
+                                          error:(NSError *_Nullable *_Nonnull)outError;
 
 @end
 

+ 157 - 46
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/NetworkInterface.m

@@ -21,21 +21,24 @@
 
 #import <net/if.h>
 #import <ifaddrs.h>
+#import <netdb.h>
+#import <netinet6/in6.h>
 #import <Network/path.h>
 #import <Network/path_monitor.h>
-
-@implementation NetworkPathState
-
-@end
+#import "DefaultRouteMonitor.h"
 
 @implementation NetworkInterface
 
-+ (NSSet<NSString*>*)activeInterfaces {
-
-    NSMutableSet *upIffList = [NSMutableSet new];
++ (NSString*_Nullable)getInterfaceAddress:(NSString*_Nonnull)interfaceName
+                                    error:(NSError *_Nullable *_Nonnull)outError {
+    *outError = nil;
 
     struct ifaddrs *interfaces;
     if (getifaddrs(&interfaces) != 0) {
+        NSString *localizedDescription = [NSString stringWithFormat:@"getifaddrs error with errno %d", errno];
+        *outError = [[NSError alloc] initWithDomain:@"iOSLibrary"
+                                               code:1
+                                           userInfo:@{NSLocalizedDescriptionKey:localizedDescription}];
         return nil;
     }
 
@@ -50,69 +53,177 @@
                 // ifa_name could be NULL
                 // https://sourceware.org/bugzilla/show_bug.cgi?id=21812
                 if (interface->ifa_name != NULL) {
-                    NSString *interfaceName = [NSString stringWithUTF8String:interface->ifa_name];
-                    [upIffList addObject:interfaceName];
+
+                    NSString *curInterfaceName = [NSString stringWithUTF8String:interface->ifa_name];
+                    if ([interfaceName isEqualToString:curInterfaceName]) {
+
+                        // Ignore IPv6 link-local addresses https://developer.apple.com/forums/thread/128215?answerId=403310022#403310022
+                        // Do not ignore link-local IPv4 addresses because it is possible the interface
+                        // is assigned one manually, or if DHCP fails, etc.
+                        if (interface->ifa_addr->sa_family == AF_INET6) {
+                            struct sockaddr_in6 *sa_in6 = (struct sockaddr_in6*)interface->ifa_addr;
+                            if (sa_in6 != NULL) {
+                                struct in6_addr i_a = sa_in6->sin6_addr;
+                                if (IN6_IS_ADDR_LINKLOCAL(&i_a)) {
+                                    // TODO: consider excluding other IP ranges
+                                    continue;
+                                }
+                            }
+                        }
+
+                        char addr[NI_MAXHOST];
+                        int ret = getnameinfo(interface->ifa_addr,
+                                              (socklen_t)interface->ifa_addr->sa_len,
+                                              addr,
+                                              (socklen_t)NI_MAXHOST,
+                                              NULL,
+                                              (socklen_t)0,
+                                              NI_NUMERICHOST);
+                        if (ret != 0) {
+                            NSString *localizedDescription = [NSString stringWithFormat:@"getnameinfo returned %d", ret];
+                            *outError = [[NSError alloc] initWithDomain:@"iOSLibrary"
+                                                                   code:1
+                                                               userInfo:@{NSLocalizedDescriptionKey:localizedDescription}];
+                            freeifaddrs(interfaces);
+                            return nil;
+                        }
+
+                        freeifaddrs(interfaces);
+
+                        NSString *resolvedAddr = [NSString stringWithUTF8String:addr];
+
+                        return resolvedAddr;
+                    }
                 }
             }
         }
     }
 
-    // Free getifaddrs data
     freeifaddrs(interfaces);
 
-    return upIffList;
+    return nil;
 }
 
-+ (NetworkPathState*)networkPathState:(NSSet<NSString*>*)activeInterfaces {
++ (NSSet<NSString*>*)activeInterfaces:(NSError *_Nullable *_Nonnull)outError {
 
-    __block NetworkPathState *state = [[NetworkPathState alloc] init];
+    *outError = nil;
 
-    dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+    NSMutableSet *upIffList = [NSMutableSet new];
 
-    nw_path_monitor_t monitor = nw_path_monitor_create();
+    struct ifaddrs *interfaces;
+    if (getifaddrs(&interfaces) != 0) {
+        NSString *localizedDescription = [NSString stringWithFormat:@"getifaddrs error with errno %d", errno];
+        *outError = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey:localizedDescription}];
+        return upIffList;
+    }
 
-    nw_path_monitor_set_update_handler(monitor, ^(nw_path_t  _Nonnull path) {
+    struct ifaddrs *interface;
+    for (interface=interfaces; interface; interface=interface->ifa_next) {
 
-        // Discover the active interface type
+        // Only IFF_UP interfaces. Loopback is ignored.
+        if (interface->ifa_flags & IFF_UP && !(interface->ifa_flags & IFF_LOOPBACK)) {
 
-        nw_interface_type_t active_interface_type = nw_interface_type_other;
+            if (interface->ifa_addr && (interface->ifa_addr->sa_family==AF_INET || interface->ifa_addr->sa_family==AF_INET6)) {
 
-        if (nw_path_uses_interface_type(path, nw_interface_type_wifi)) {
-            active_interface_type = nw_interface_type_wifi;
-        } else if (nw_path_uses_interface_type(path, nw_interface_type_cellular)) {
-            active_interface_type = nw_interface_type_cellular;
-        } else if (nw_path_uses_interface_type(path, nw_interface_type_wired)) {
-            active_interface_type = nw_interface_type_wired;
-        } else if (nw_path_uses_interface_type(path, nw_interface_type_loopback)) {
-            active_interface_type = nw_interface_type_loopback;
-        } else {
-            active_interface_type = nw_interface_type_other;
+                // ifa_name could be NULL
+                // https://sourceware.org/bugzilla/show_bug.cgi?id=21812
+                if (interface->ifa_name != NULL) {
+                    NSString *interfaceName = [NSString stringWithUTF8String:interface->ifa_name];
+                    [upIffList addObject:interfaceName];
+                }
+            }
         }
+    }
 
-        // Map the active interface type to the interface itself
-        nw_path_enumerate_interfaces(path, ^bool(nw_interface_t  _Nonnull interface) {
+    // Free getifaddrs data
+    freeifaddrs(interfaces);
 
-            if (nw_interface_get_type(interface) == active_interface_type) {
-                NSString *interfaceName = [NSString stringWithUTF8String:nw_interface_get_name(interface)];
-                if (state.defaultActiveInterface == NULL && (activeInterfaces == nil || [activeInterfaces containsObject:interfaceName])) {
-                    state.defaultActiveInterface = interface;
-                    return false;
-                }
-            }
+    return upIffList;
+}
 
-            // Continue searching
-            return true;
-        });
+/*!
+ @brief Returns name of default active network interface from the provided list of active interfaces.
+ @param upIffList List of active network interfaces.
+ @return Active interface name, nil otherwise.
+ @warning Use DefaultRouteMonitor instead on iOS 12.0+.
+ */
++ (NSString *)getActiveInterface:(NSSet<NSString*>*)upIffList
+            currentNetworkStatus:(NetworkReachability)currentNetworkStatus {
+
+    // TODO: following is a heuristic for choosing active network interface
+    // Only Wi-Fi and Cellular interfaces are considered
+    // @see : https://forums.developer.apple.com/thread/76711
+    NSArray *iffPriorityList = @[@"en0", @"pdp_ip0"];
+    if (currentNetworkStatus == NetworkReachabilityReachableViaCellular) {
+        iffPriorityList = @[@"pdp_ip0", @"en0"];
+    }
+    for (NSString * key in iffPriorityList) {
+        for (NSString * upIff in upIffList) {
+            if ([key isEqualToString:upIff]) {
+                return [NSString stringWithString:upIff];
+            }
+        }
+    }
 
-        dispatch_semaphore_signal(sem);
-    });
+    return nil;
+}
 
-    nw_path_monitor_set_queue(monitor, dispatch_queue_create("com.psiphon3.library.NWInterfaceNWPathMonitorQueue", DISPATCH_QUEUE_SERIAL));
-    nw_path_monitor_start(monitor);
++ (NSString*)getActiveInterfaceWithReachability:(id<ReachabilityProtocol>)reachability
+                        andCurrentNetworkStatus:(NetworkReachability)currentNetworkStatus
+                                          error:(NSError *_Nullable *_Nonnull)outError {
+
+    *outError = nil;
+
+    NSString *activeInterface;
+
+    if (@available(iOS 12.0, *)) {
+        // Note: it is hypothetically possible that NWPathMonitor emits a new path after
+        // getActiveInterfaceWithReachability is called. This creates a race between
+        // DefaultRouteMonitor updating its internal state and getActiveInterfaceWithReachability
+        // retrieving the active interface from that internal state.
+        // Therefore the following sequence of events is possible:
+        // - NWPathMonitor emits path that is satisfied or satisfiable
+        // - ReachabilityProtocol consumer sees there is connectivity and calls
+        //   getActiveInterfaceWithReachability
+        // - NWPathMonitor emits path that is unsatisfied or invalid
+        // - getActiveInterfaceWithReachability either: a) does not observe update and returns the
+        //   previously active interface; or b) observes update and cannot find active interface.
+        // In both scenarios the reachability state will change to unreachable and it is up to the
+        // consumer to call getActiveInterfaceWithReachability again once it becomes reachable again.
+        DefaultRouteMonitor *gwMonitor = (DefaultRouteMonitor*)reachability;
+        if (gwMonitor == nil) {
+            *outError = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: @"getActiveInterfaceWithReachability: DefaultRouteMonitor nil"}];
+            return @"";
+        }
+        NetworkPathState *state = [gwMonitor pathState];
+        if (state == nil) {
+            *outError = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: @"getActiveInterfaceWithReachability: network path state nil"}];
+            return @"";
+        }
+        // Note: could fallback on heuristic for iOS <12.0 if nil
+        activeInterface = state.defaultActiveInterfaceName;
+    } else {
+        NSError *err;
+        NSSet<NSString*>* upIffList = [NetworkInterface activeInterfaces:&err];
+        if (err != nil) {
+            NSString *localizedDescription = [NSString stringWithFormat:@"getActiveInterfaceWithReachability: error getting active interfaces %@", err.localizedDescription];
+            *outError = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: localizedDescription}];
+            return @"";
+        }
+        if (upIffList == nil) {
+            *outError = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: @"getActiveInterfaceWithReachability: no active interfaces"}];
+            return @"";
+        }
+        activeInterface = [NetworkInterface getActiveInterface:upIffList
+                                          currentNetworkStatus:currentNetworkStatus];
+    }
 
-    dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
+    if (activeInterface == nil) {
+        *outError = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: @"getActiveInterfaceWithReachability: no active interface"}];
+        return @"";
+    }
 
-    return state;
+    return activeInterface;
 }
 
 @end

+ 0 - 28
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability+HasNetworkConnectivity.m

@@ -1,28 +0,0 @@
-/*
- * Copyright (c) 2020, 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/>.
- *
- */
-
-#import "Reachability+HasNetworkConnectivity.h"
-
-@implementation Reachability (NetworkConnectivity)
-
-- (long)hasNetworkConnectivity {
-    return [self currentReachabilityStatus] != NotReachable;
-}
-
-@end

+ 75 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability/DefaultRouteMonitor.h

@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2021, 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/>.
+ *
+ */
+
+#import <Foundation/Foundation.h>
+#import <Network/path.h>
+#import "ReachabilityProtocol.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// NetworkPathState represents the state of the network path on the device.
+@interface NetworkPathState : NSObject
+
+/// Reachability status.
+@property (nonatomic, readonly) NetworkReachability status;
+
+/// Network path state.
+@property (nonatomic, nullable, readonly) nw_path_t path;
+
+/// Default active interface available to the network path.
+@property (nonatomic, nullable, readonly) NSString* defaultActiveInterfaceName;
+
+@end
+
+/// ReachabilityChangedNotification represents the reachability state on the device.
+@interface ReachabilityChangedNotification : NSObject
+
+/// Current reachability status.
+@property (nonatomic, readonly) NetworkReachability reachabilityStatus;
+
+/// Name of current default active interface. If nil, then there is no such interface.
+@property (nonatomic, nullable, readonly) NSString* curDefaultActiveInterfaceName;
+
+/// Name of previous default active interface. If nil, then there was no default active interface previously or the previous default active
+/// interface was not capable of sending or receiving network data at the time.
+@property (nonatomic, nullable, readonly) NSString* prevDefaultActiveInterfaceName;
+
+@end
+
+/// DefaultRouteMonitor monitors changes to the default route on the device and whether that route is capable of sending and
+/// receiving network data.
+@interface DefaultRouteMonitor : NSObject <ReachabilityProtocol>
+
+/// Returns the state of the default route on the device. If nil, then there is no usable route available for sending or receiving network data.
+@property (atomic, readonly) NetworkPathState *pathState;
+
+- (instancetype)init API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
+
+- (id)initWithLogger:(void (^__nonnull)(NSString *_Nonnull))logger API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
+
+// Denote ReachabilityProtocol availability.
+- (BOOL)startNotifier API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
+- (void)stopNotifier API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
++ (NSString*)reachabilityChangedNotification;
+- (NetworkReachability)reachabilityStatus API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
+- (NSString*)reachabilityStatusDebugInfo API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0));
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 442 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability/DefaultRouteMonitor.m

@@ -0,0 +1,442 @@
+/*
+ * Copyright (c) 2021, 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/>.
+ *
+ */
+
+#import "DefaultRouteMonitor.h"
+#import "NetworkInterface.h"
+#import <net/if.h>
+#import <ifaddrs.h>
+#import <netinet/in.h>
+#import <netinet6/in6.h>
+
+@interface NetworkPathState ()
+/// See comment in DefaultRouteMonitor.h
+@property (nonatomic) NetworkReachability status;
+/// See comment in DefaultRouteMonitor.h
+@property (nonatomic, nullable) nw_path_t path;
+/// See comment in DefaultRouteMonitor.h
+@property (nonatomic, nullable) NSString* defaultActiveInterfaceName;
+@end
+
+@implementation NetworkPathState
+
+- (instancetype)initWithNetworkReachability:(NetworkReachability)networkReachability
+                                       path:(nw_path_t)path
+                 defaultActiveInterfaceName:(NSString*)defaultActiveInterfaceName {
+    self = [super init];
+    if (self) {
+        self->_status = networkReachability;
+        self->_path = path;
+        self->_defaultActiveInterfaceName = defaultActiveInterfaceName;
+    }
+    return self;
+}
+
+@end
+
+@interface ReachabilityChangedNotification ()
+/// See comment in DefaultRouteMonitor.h
+@property (nonatomic) NetworkReachability reachabilityStatus;
+/// See comment in DefaultRouteMonitor.h
+@property (nonatomic, nullable) NSString *curDefaultActiveInterfaceName;
+/// See comment in DefaultRouteMonitor.h
+@property (nonatomic, nullable) NSString *prevDefaultActiveInterfaceName;
+@end
+
+@implementation ReachabilityChangedNotification
+
+- (instancetype)initWithReachabilityStatus:(NetworkReachability)networkReachability
+             curDefaultActiveInterfaceName:(NSString*)curDefaultActiveInterfaceName
+            prevDefaultActiveInterfaceName:(NSString*)prevDefaultActiveInterfaceName {
+    self = [super init];
+    if (self) {
+        self->_reachabilityStatus = networkReachability;
+        self->_curDefaultActiveInterfaceName = curDefaultActiveInterfaceName;
+        self->_prevDefaultActiveInterfaceName = prevDefaultActiveInterfaceName;
+    }
+    return self;
+}
+
+@end
+
+@interface DefaultRouteMonitor ()
+@property (atomic) NetworkPathState *pathState;
+@end
+
+@implementation DefaultRouteMonitor {
+    nw_path_monitor_t monitor;
+    dispatch_queue_t nwPathMonitorQueue;
+    dispatch_queue_t notifQueue;
+
+    void (^logger) (NSString *_Nonnull);
+}
+
+- (void)initialize API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+    self.pathState = [[NetworkPathState alloc] initWithNetworkReachability:NetworkReachabilityNotReachable
+                                                                      path:nil
+                                                defaultActiveInterfaceName:nil];
+    self->nwPathMonitorQueue = dispatch_queue_create("com.psiphon3.library.DefaultRouteMonitorNWPathMonitorQueue", DISPATCH_QUEUE_SERIAL);
+    self->notifQueue = dispatch_queue_create("com.psiphon3.library.DefaultRouteMonitorNotificationQueue", DISPATCH_QUEUE_SERIAL);
+}
+
+- (instancetype)init {
+    self = [super init];
+    if (self) {
+        [self initialize];
+    }
+    return self;
+}
+
+- (instancetype)initWithLogger:(void (^__nonnull)(NSString *_Nonnull))logger {
+    self = [super init];
+    if (self) {
+        self->logger = logger;
+        [self initialize];
+    }
+    return self;
+}
+
+- (void)log:(NSString*)notice {
+    if (self->logger != nil) {
+        self->logger(notice);
+    }
+}
+
+nw_interface_type_t
+nw_path_interface_type(nw_path_t path) API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+    // Discover active interface type. Follows: https://developer.apple.com/forums/thread/105822?answerId=322343022#322343022.
+    if (nw_path_uses_interface_type(path, nw_interface_type_wifi)) {
+        return nw_interface_type_wifi;
+    } else if (nw_path_uses_interface_type(path, nw_interface_type_cellular)) {
+        return nw_interface_type_cellular;
+    } else if (nw_path_uses_interface_type(path, nw_interface_type_wired)) {
+        return nw_interface_type_wired;
+    } else if (nw_path_uses_interface_type(path, nw_interface_type_loopback)) {
+        return nw_interface_type_loopback;
+    } else {
+        return nw_interface_type_other;
+    }
+}
+
+NetworkReachability nw_interface_type_network_reachability(nw_interface_type_t interface_type) {
+    if (interface_type == nw_interface_type_wifi) {
+        return NetworkReachabilityReachableViaWiFi;
+    } else if (interface_type == nw_interface_type_cellular) {
+        return NetworkReachabilityReachableViaCellular;
+    } else if (interface_type == nw_interface_type_wired) {
+        return NetworkReachabilityReachableViaWired;
+    } else if (interface_type == nw_interface_type_loopback) {
+        return NetworkReachabilityReachableViaLoopback;
+    } else {
+        return NetworkReachabilityReachableViaUnknown;
+    }
+}
+
+- (void)start API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+    @synchronized (self) {
+        // Ensure previous monitor cancelled
+        if (self->monitor != nil) {
+            nw_path_monitor_cancel(self->monitor);
+        }
+        self->monitor = nw_path_monitor_create();
+
+        nw_path_monitor_set_queue(self->monitor, self->nwPathMonitorQueue);
+        __block dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+
+        nw_path_monitor_set_update_handler(self->monitor, ^(nw_path_t  _Nonnull path) {
+            // Do not emit notification on first update. PsiphonTunnel expects that only
+            // subsequent path updates will be emitted and will invalidate the DNS cache once the
+            // first notification is received.
+            BOOL emitNotification = sem == NULL;
+            [self pathUpdateHandler:path emitNotification:emitNotification];
+            if (sem != NULL) {
+                dispatch_semaphore_signal(sem);
+                @synchronized (self) {
+                    // Release memory after `start` has completed. Otherwise we may set `sem` to
+                    // NULL before dispatch_semaphore_wait is called in the enclosing scope and the
+                    // program will crash.
+                    sem = NULL;
+                }
+            }
+        });
+        nw_path_monitor_start(self->monitor);
+
+        // Wait for the current path to be emitted before returning to ensure this instance is
+        // populated with the current network state. PsiphonTunnel depends on this guarantee.
+        // NOTE: This null guard defends against nw_path_monitor_start calling the update handler
+        // synchronously before returning, e.g. with dispatch_sync, which will set `sem` to NULL
+        // because @synchronized provides a reentrant thread level locking mechanism.
+        if (sem != NULL) {
+            dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
+        }
+    }
+}
+
+- (void)stop API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+    @synchronized (self) {
+        // Note: this monitor cannot be used after being cancelled. Its update handler will not
+        // fire again and cannot be restarted with nw_path_monitor_start. A new monitor must be
+        // created and started.
+        nw_path_monitor_cancel(self->monitor);
+        self->monitor = nil;
+    }
+}
+
+- (void)pathUpdateHandler:(nw_path_t _Nonnull)path emitNotification:(BOOL)emitNotification API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+
+    if (self.pathState.path != nil && pathUpdateIsRedundant(self.pathState.path, path)) {
+        // Do nothing, path update is redundant.
+        return;
+    }
+
+    [self log:[NSString stringWithFormat:@"new path: %@",
+               [DefaultRouteMonitor pathDebugInfo:path]]];
+
+    NetworkPathState *newPathState = [[NetworkPathState alloc] init];
+    newPathState.path = path;
+    NSString *prevDefaultActiveInterfaceName = self.pathState.defaultActiveInterfaceName;
+
+    nw_path_status_t status = nw_path_get_status(path);
+    if (status == nw_path_status_invalid || status == nw_path_status_unsatisfied) {
+        newPathState.status = NetworkReachabilityNotReachable;
+    } else if (status == nw_path_status_satisfied || status == nw_path_status_satisfiable) {
+
+        // Network is, or could, be reachable. Determine interface corresponding to this
+        // path.
+
+        nw_interface_type_t active_interface_type = nw_path_interface_type(path);
+        newPathState.status = nw_interface_type_network_reachability(active_interface_type);
+
+        NSError *err;
+        NSSet<NSString*>* activeInterfaces = [NetworkInterface activeInterfaces:&err];
+        if (err != nil) {
+            [self log:[NSString stringWithFormat:@"failed to get active interfaces %@", err.localizedDescription]];
+            // Continue. activeInterfaces will be an empty set (non-nil) and we still want
+            // to log interfaces enumerated with nw_path_enumerate_interfaces for debugging.
+        }
+        [self log:[NSString stringWithFormat:@"active interfaces %@", [[activeInterfaces allObjects] componentsJoinedByString:@","]]];
+
+        NSMutableArray<NSString*> *candidateInterfaces = [[NSMutableArray alloc] init];
+        // Map the active interface type to the interface itself
+        nw_path_enumerate_interfaces(path, ^bool(nw_interface_t  _Nonnull interface) {
+            nw_interface_type_t interfaceType = nw_interface_get_type(interface);
+            [self log:[NSString stringWithFormat:@"enumerated interface %@ with type %d",
+                       [NSString stringWithUTF8String:nw_interface_get_name(interface)], interfaceType]];
+
+            if (interfaceType == active_interface_type) {
+                NSString *interfaceName = [NSString stringWithUTF8String:nw_interface_get_name(interface)];
+                if ([activeInterfaces containsObject:interfaceName]) {
+                    [candidateInterfaces addObject:interfaceName];
+                    // Note: could return false here to end enumeration and choose first
+                    // candidate interface.
+                    return true;
+                }
+            }
+            // Continue searching
+            return true;
+        });
+        [self log:[NSString stringWithFormat:@"%lu candidate interfaces",
+                   (unsigned long)[candidateInterfaces count]]];
+
+        if ([candidateInterfaces count] > 0) {
+            // Arbitrarily choose first interface
+            NSString *interfaceName = [candidateInterfaces objectAtIndex:0];
+            newPathState.defaultActiveInterfaceName = interfaceName;
+            [self log:[NSString stringWithFormat:@"active interface %@", interfaceName]];
+        } else {
+            // This should never happen
+        }
+    } else {
+        // Unhandled case. Should never happen.
+    }
+    self.pathState = newPathState;
+
+    if (emitNotification == TRUE) {
+        // Backwards compatibility with Reachability
+        ReachabilityChangedNotification *notif =
+            [[ReachabilityChangedNotification alloc]
+             initWithReachabilityStatus:self.pathState.status
+             curDefaultActiveInterfaceName:newPathState.defaultActiveInterfaceName
+             prevDefaultActiveInterfaceName:prevDefaultActiveInterfaceName];
+        dispatch_async(self->notifQueue, ^{
+            [[NSNotificationCenter defaultCenter]
+             postNotificationName:[DefaultRouteMonitor reachabilityChangedNotification]
+             object:notif];
+        });
+    }
+}
+
+/// Returns true if the network state represented by newPath is considered equivalent, for our purposes, to that represented by oldPath;
+/// otherwise returns false.
+bool pathUpdateIsRedundant(nw_path_t oldPath, nw_path_t newPath) API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+
+    // Note: nw_path_is_equal may return FALSE even though the paths are identical when comparing
+    // all the information that can be gathered with the public nw_path APIs.
+    if (nw_path_is_equal(oldPath, newPath)) {
+        return TRUE;
+    }
+
+    nw_interface_type_t interfaceType = nw_path_interface_type(oldPath);
+    if (interfaceType != nw_path_interface_type(newPath)) {
+        return FALSE;
+    }
+
+    if (nw_path_get_status(oldPath) != nw_path_get_status(newPath)) {
+        return FALSE;
+    }
+
+    // Compare path interfaces that match the active interface type
+
+    NSArray<nw_interface_t>* pathInterfaces = [DefaultRouteMonitor pathInterfaces:oldPath withType:interfaceType];
+    NSArray<nw_interface_t>* otherPathInterfaces = [DefaultRouteMonitor pathInterfaces:newPath withType:interfaceType];
+    if ([pathInterfaces count] != [otherPathInterfaces count]) {
+        return FALSE;
+    }
+
+    // Note: we do not compare the values returned by other public nw_path_* APIs because testing
+    // has shown us these values can change when the active interface has not and we want to reduce
+    // the chance of false negatives.
+
+    return TRUE;
+}
+
++ (NSString*)pathDebugInfo:(nw_path_t)path API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+
+    if (path == nil) {
+        return @"state nil";
+    }
+
+    NSString *constrained = @"UNAVAILABLE";
+    if (@available(iOS 13.0, *)) {
+        constrained = [NSString stringWithFormat:@"%d", nw_path_is_constrained(path)];
+    }
+
+    NSString *unsatisfiedReason = @"UNAVAILABLE";
+    if (@available(iOS 14.2, *)) {
+        nw_path_unsatisfied_reason_t reason = nw_path_get_unsatisfied_reason(path);
+        if (reason == nw_path_unsatisfied_reason_wifi_denied) {
+            unsatisfiedReason = @"WIFI_DENIED";
+        } else if (reason == nw_path_unsatisfied_reason_cellular_denied) {
+            unsatisfiedReason = @"CELLULAR_DENIED";
+        } else if (reason == nw_path_unsatisfied_reason_local_network_denied) {
+            unsatisfiedReason = @"LOCAL_NETWORK_DENIED";
+        } else if (reason == nw_path_unsatisfied_reason_not_available) {
+            unsatisfiedReason = @"NOT_AVAILABLE";
+        } else {
+            unsatisfiedReason = @"UNKNOWN";
+        }
+    }
+
+    NSString *s = [NSString stringWithFormat:
+                   @"status %@, "
+                   "active_interface_type %@, "
+                   "path_is_expensive %d, "
+                   "path_is_constrained %@, "
+                   "path_has_ipv4 %d, "
+                   "path_has_ipv6 %d, "
+                   "path_has_dns %d, "
+                   "unsatisfied_reason %@",
+                   [DefaultRouteMonitor pathStatusToString:nw_path_get_status(path)],
+                   [DefaultRouteMonitor interfaceTypeToString:nw_path_interface_type(path)],
+                   nw_path_is_expensive(path), constrained, nw_path_has_ipv4(path),
+                   nw_path_has_ipv6(path), nw_path_has_dns(path), unsatisfiedReason];
+    return s;
+}
+
+#pragma mark ReachabilityProtocol
+
++ (NSString*)reachabilityChangedNotification {
+    return @"kNetworkReachabilityChangedNotification";
+}
+
+- (BOOL)startNotifier {
+    [self log:@"starting NWPathMonitor"];
+    [self start];
+    return TRUE;
+}
+
+- (void)stopNotifier {
+    [self log:@"stopping NWPathMonitor"];
+    [self stop];
+}
+
+- (NetworkReachability)reachabilityStatus {
+    // Note: alternatively we could initialize a temporary NWPathMonitor instance and sample the
+    // reachability state by synchronously waiting for its initial update.
+    return self.pathState.status;
+}
+
+- (NSString*)reachabilityStatusDebugInfo {
+    return [DefaultRouteMonitor pathDebugInfo:self.pathState.path];
+}
+
+#pragma mark Helpers (private)
+
++ (NSString*)interfaceTypeToString:(nw_interface_type_t)type {
+    if (type == nw_interface_type_wifi) {
+        return @"WIFI";
+    } else if (type == nw_interface_type_cellular) {
+        return @"CELLULAR";
+    } else if (type == nw_interface_type_wired) {
+        return @"WIRED";
+    } else if (type == nw_interface_type_loopback) {
+        return @"LOOPBACK";
+    } else if (type == nw_interface_type_other) {
+        return @"OTHER";
+    } else {
+        return @"UNKNOWN";
+    }
+}
+
++ (NSString*)pathStatusToString:(nw_path_status_t)status {
+    if (status == nw_path_status_satisfied) {
+        return @"SATISFIED";
+    } else if (status == nw_path_status_satisfiable) {
+        return @"SATISFIABLE";
+    } else if (status == nw_path_status_unsatisfied) {
+        return @"UNSATISFIED";
+    } else if (status == nw_path_status_invalid) {
+        return @"INVALID";
+    } else {
+        return @"UNKNOWN";
+    }
+}
+
++ (NSArray<nw_interface_t>*)pathInterfaces:(nw_path_t)path withType:(nw_interface_type_t)interfaceType API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+    NSMutableArray<nw_interface_t>* interfaces = [[NSMutableArray alloc] init];
+
+    nw_path_enumerate_interfaces(path, ^bool(nw_interface_t  _Nonnull interface) {
+        if (nw_interface_get_type(interface) == interfaceType) {
+            [interfaces addObject:interface];
+        }
+        return TRUE;
+    });
+
+    return interfaces;
+}
+
+bool interfaceIsEqual(nw_interface_t interface, nw_interface_t otherInterface) API_AVAILABLE(macos(10.14), ios(12.0), watchos(5.0), tvos(12.0)) {
+    if (nw_interface_get_index(interface) != nw_interface_get_index(otherInterface) ||
+        strcmp(nw_interface_get_name(interface), nw_interface_get_name(otherInterface)) != 0 ||
+        nw_interface_get_type(interface) != nw_interface_get_type(otherInterface)) {
+        return FALSE;
+    }
+    return TRUE;
+}
+
+@end

+ 4 - 6
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability+HasNetworkConnectivity.h → MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability/Reachability+ReachabilityProtocol.h

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2020, Psiphon Inc.
+ * Copyright (c) 2021, Psiphon Inc.
  * All rights reserved.
  *
  * This program is free software: you can redistribute it and/or modify
@@ -19,14 +19,12 @@
 
 #import <Foundation/Foundation.h>
 #import "Reachability.h"
+#import "ReachabilityProtocol.h"
 
 NS_ASSUME_NONNULL_BEGIN
 
-@interface Reachability (HasNetworkConnectivity)
-
-/// Returns 1 if there is network connectivity. Otherwise returns 0.
-- (long)hasNetworkConnectivity;
-
+@interface Reachability (ReachabilityProtocol) <ReachabilityProtocol>
 @end
 
 NS_ASSUME_NONNULL_END
+

+ 47 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability/Reachability+ReachabilityProtocol.m

@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2021, 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/>.
+ *
+ */
+
+#import "Reachability+ReachabilityProtocol.h"
+
+@implementation Reachability (ReachabilityProtocol)
+
++ (NSString*)reachabilityChangedNotification {
+    return kReachabilityChangedNotification;
+}
+
+- (NetworkReachability)reachabilityStatus {
+    NetworkStatus status = [self currentReachabilityStatus];
+    switch (status) {
+        case NotReachable:
+            return NetworkReachabilityNotReachable;
+        case ReachableViaWiFi:
+            return NetworkReachabilityReachableViaWiFi;
+        case ReachableViaWWAN:
+            return NetworkReachabilityReachableViaCellular;
+        default:
+            [NSException raise:@"unexpected reachability status" format:@"%ld", (long)status];
+            return NetworkReachabilityNotReachable;
+    }
+}
+
+- (NSString*)reachabilityStatusDebugInfo {
+    return [self currentReachabilityFlagsToString];
+}
+
+@end

+ 59 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Network/Reachability/ReachabilityProtocol.h

@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2021, 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/>.
+ *
+ */
+
+#import <Foundation/Foundation.h>
+
+typedef enum : NSInteger {
+    NetworkReachabilityNotReachable = 0,
+    NetworkReachabilityReachableViaWiFi,
+    NetworkReachabilityReachableViaCellular,
+    NetworkReachabilityReachableViaWired,
+    NetworkReachabilityReachableViaLoopback,
+    NetworkReachabilityReachableViaUnknown
+} NetworkReachability;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// ReachabilityProtocol is a protocol for monitoring the reachability of a target network destination. For example, a protocol
+/// implementation could provide reachability information for the default gateway over a specific network interface.
+/// @note The purpose of ReachabilityProtocol is to bridge the gap between Apple's old Reachability APIs and the new
+/// NWPathMonitor (iOS 12.0+) with a common interface that allows each to be used interchangeably. Using a common interface
+/// simplifies supporting older clients which cannot target NWPathMonitor until the minimum iOS target is 12.0+, at which point the
+/// code targeting the legacy Reachability APIs can be removed.
+@protocol ReachabilityProtocol <NSObject>
+
+/// Name of reachability notifications emitted from the default notification center. See comment for `startNotifier`.
++ (NSString*)reachabilityChangedNotification;
+
+/// Start listening for reachability changes. A notification with the name returned by `reachabilityChangedNotification` will be emitted
+/// from the default notification center until `stopNotifier` is called.
+- (BOOL)startNotifier;
+
+/// Stop listening for reachability changes.
+- (void)stopNotifier;
+
+/// Return current reachability status.
+- (NetworkReachability)reachabilityStatus;
+
+/// Return debug string which represents the current network state for logging.
+- (NSString*)reachabilityStatusDebugInfo;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 3 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Psiphon/PsiphonProviderNetwork.h

@@ -23,6 +23,9 @@
 NS_ASSUME_NONNULL_BEGIN
 
 @interface PsiphonProviderNetwork : NSObject <GoPsiPsiphonProviderNetwork>
+
+- (instancetype)initWithLogger:(void (^__nonnull)(NSString *_Nonnull))logger;
+
 @end
 
 NS_ASSUME_NONNULL_END

+ 44 - 6
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Psiphon/PsiphonProviderNetwork.m

@@ -18,25 +18,51 @@
  */
 
 #import "PsiphonProviderNetwork.h"
+#import "DefaultRouteMonitor.h"
 #import "IPv6Synthesizer.h"
-#import "Reachability.h"
-#import "Reachability+HasNetworkConnectivity.h"
 #import "NetworkID.h"
+#import "Reachability.h"
+#import "Reachability+ReachabilityProtocol.h"
+#import "ReachabilityProtocol.h"
 
 @implementation PsiphonProviderNetwork {
-    Reachability *reachability;
+    id<ReachabilityProtocol> reachability;
+    void (^logger) (NSString *_Nonnull);
+}
+
+- (void)initialize {
+    if (@available(iOS 12.0, *)) {
+        self->reachability = [[DefaultRouteMonitor alloc] init];
+    } else {
+        self->reachability = [Reachability reachabilityForInternetConnection];
+    }
 }
 
 - (id)init {
     self = [super init];
     if (self) {
-        self->reachability = [Reachability reachabilityForInternetConnection];
+        [self initialize];
     }
     return self;
 }
 
+- (instancetype)initWithLogger:(void (^__nonnull)(NSString *_Nonnull))logger {
+    self = [super init];
+    if (self) {
+        [self initialize];
+        self->logger = logger;
+    }
+    return self;
+}
+
+- (void)logMessage:(NSString*)notice {
+    if (self->logger != nil) {
+        self->logger(notice);
+    }
+}
+
 - (long)hasNetworkConnectivity {
-    return [self->reachability hasNetworkConnectivity];
+    return [self->reachability reachabilityStatus] != NetworkReachabilityNotReachable;
 }
 
 
@@ -45,7 +71,19 @@
 }
 
 - (NSString *)getNetworkID {
-    return [NetworkID getNetworkID:reachability.currentReachabilityStatus];
+    NSError *warn;
+    NSString *networkID = [NetworkID getNetworkIDWithReachability:self->reachability
+                                          andCurrentNetworkStatus:self->reachability.reachabilityStatus
+                                                          warning:&warn];
+    if (warn != nil) {
+        [self logMessage:[NSString stringWithFormat:@"error getting network ID: %@", warn.localizedDescription]];
+    }
+    return networkID;
+}
+
+- (long)hasIPv6Route {
+    // Unused on iOS.
+    return FALSE;
 }
 
 @end

+ 4 - 2
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -23,6 +23,8 @@
  */
 
 #import "Reachability.h"
+#import "DefaultRouteMonitor.h"
+#import "ReachabilityProtocol.h"
 #import "JailbreakCheck.h"
 #import "PsiphonClientPlatform.h"
 
@@ -180,7 +182,7 @@ Called when the device's Internet connection state has changed.
 This may mean that it had connectivity and now doesn't, or went from Wi-Fi to
 WWAN or vice versa or VPN state changed
 */
-- (void)onInternetReachabilityChanged:(Reachability * _Nonnull)currentReachability;
+- (void)onInternetReachabilityChanged:(NetworkReachability)currentReachability;
 
 /*!
  Called when tunnel-core determines which server egress regions are available
@@ -381,7 +383,7 @@ Returns the path where the rotated notices file will be created.
  disconnected state.
  @return The current reachability status.
  */
-- (BOOL)getNetworkReachabilityStatus:(NetworkStatus * _Nonnull)status;
+- (BOOL)getNetworkReachabilityStatus:(NetworkReachability * _Nonnull)status;
 
 /*!
  Provides the port number of the local SOCKS proxy. Only valid when currently connected (will return 0 otherwise).

+ 132 - 93
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -28,7 +28,6 @@
 #import "PsiphonProviderNoticeHandlerShim.h"
 #import "PsiphonProviderNetwork.h"
 #import "PsiphonTunnel.h"
-#import "Reachability+HasNetworkConnectivity.h"
 #import "Backups.h"
 #import "json-framework/SBJson4.h"
 #import "NetworkID.h"
@@ -37,6 +36,9 @@
 #import <netdb.h>
 #import "PsiphonClientPlatform.h"
 #import "Redactor.h"
+#import "ReachabilityProtocol.h"
+#import "Reachability+ReachabilityProtocol.h"
+#import "DefaultRouteMonitor.h"
 
 NSErrorDomain _Nonnull const PsiphonTunnelErrorDomain = @"com.psiphon3.ios.PsiphonTunnelErrorDomain";
 
@@ -109,8 +111,8 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     _Atomic NSInteger localSocksProxyPort;
     _Atomic NSInteger localHttpProxyPort;
 
-    Reachability* reachability;
-    _Atomic NetworkStatus currentNetworkStatus;
+    id<ReachabilityProtocol> reachability;
+    _Atomic NetworkReachability currentNetworkStatus;
 
     BOOL tunnelWholeDevice;
     _Atomic BOOL usingNoticeFiles;
@@ -125,7 +127,28 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 }
 
 - (id)init {
-    self.tunneledAppDelegate = nil;
+    self = [super init];
+    if (self) {
+        [self initializeWithAppDelegate:nil];
+    }
+    return self;
+}
+
+- (id)initWithAppDelegate:(id<TunneledAppDelegate> _Nullable)appDelegate {
+    self = [super init];
+    if (self) {
+        [self initializeWithAppDelegate:appDelegate];
+    }
+    return self;
+}
+
+- (void)initializeWithAppDelegate:(id<TunneledAppDelegate> _Nullable)appDelegate {
+
+    // Set delegate first so it receives any initialization logs
+    self.tunneledAppDelegate = appDelegate;
+
+    // Must be initialized for logging
+    rfc3339Formatter = [PsiphonTunnel rfc3339Formatter];
 
     self->workQueue = dispatch_queue_create("com.psiphon3.library.WorkQueue", DISPATCH_QUEUE_SERIAL);
     self->callbackQueue = dispatch_queue_create("com.psiphon3.library.CallbackQueue", DISPATCH_QUEUE_SERIAL);
@@ -134,18 +157,22 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     atomic_init(&self->connectionState, PsiphonConnectionStateDisconnected);
     atomic_init(&self->localSocksProxyPort, 0);
     atomic_init(&self->localHttpProxyPort, 0);
-    self->reachability = [Reachability reachabilityForInternetConnection];
-    atomic_init(&self->currentNetworkStatus, NotReachable);
+    // reachability for the default route (destination 0.0.0.0/0)
+    if (@available(iOS 12.0, *)) {
+        void (^logNotice)(NSString * _Nonnull) = ^void(NSString * _Nonnull noticeJSON) {
+            [self logMessage:[@"DefaultRouteMonitor: " stringByAppendingString:noticeJSON]];
+        };
+        self->reachability = [[DefaultRouteMonitor alloc] initWithLogger:logNotice];
+    } else {
+        self->reachability = [Reachability reachabilityForInternetConnection];
+    }
+    atomic_init(&self->currentNetworkStatus, NetworkReachabilityNotReachable);
     self->tunnelWholeDevice = FALSE;
     atomic_init(&self->usingNoticeFiles, FALSE);
 
     // Use the workaround, comma-delimited format required for gobind.
     self->initialDNSCache = [[self getSystemDNSServers] componentsJoinedByString:@","];
     atomic_init(&self->useInitialDNS, TRUE);
-
-    rfc3339Formatter = [PsiphonTunnel rfc3339Formatter];
-    
-    return self;
 }
 
 #pragma mark - PsiphonTunnel public methods
@@ -186,7 +213,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
         static PsiphonTunnel *sharedInstance = nil;
         static dispatch_once_t onceToken = 0;
         dispatch_once(&onceToken, ^{
-            sharedInstance = [[self alloc] init];
+            sharedInstance = [[self alloc] initWithAppDelegate:tunneledAppDelegate];
         });
 
         [sharedInstance stop];
@@ -300,6 +327,8 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 
         [self changeConnectionStateTo:PsiphonConnectionStateConnecting evenIfSameState:NO];
 
+        [self startInternetReachabilityMonitoring];
+
         @try {
             NSError *e = nil;
 
@@ -337,8 +366,6 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
             return FALSE;
         }
 
-        [self startInternetReachabilityMonitoring];
-
         [self logMessage:@"Psiphon library started"];
         
         return TRUE;
@@ -405,7 +432,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return atomic_load(&self->connectionState);
 }
 
-- (BOOL)getNetworkReachabilityStatus:(NetworkStatus * _Nonnull)status {
+- (BOOL)getNetworkReachabilityStatus:(NetworkReachability * _Nonnull)status {
     PsiphonConnectionState connState = [self getConnectionState];
     if (connState == PsiphonConnectionStateDisconnected) {
         return FALSE;
@@ -1168,30 +1195,16 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
         return @"";
     }
 
-    NSSet<NSString*>* upIffList = NetworkInterface.activeInterfaces;
-    if (upIffList == nil) {
-        *error = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: @"bindToDevice: no active interfaces"}];
+    NSError *err;
+    NSString *activeInterface = [NetworkInterface getActiveInterfaceWithReachability:self->reachability
+                                                             andCurrentNetworkStatus:atomic_load(&self->currentNetworkStatus)
+                                                                               error:&err];
+    if (err != nil) {
+        NSString *localizedDescription = [NSString stringWithFormat:@"bindToDevice: error getting active interface %@", err.localizedDescription];
+        *error = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey:localizedDescription}];
         return @"";
     }
 
-    NSString *activeInterface;
-
-    if (@available(iOS 12.0, *)) {
-
-        NetworkPathState *state = [NetworkInterface networkPathState:upIffList];
-
-        if (state.defaultActiveInterface != nil) {
-            const char *interfaceName = nw_interface_get_name(state.defaultActiveInterface);
-            activeInterface = [NSString stringWithUTF8String:interfaceName];
-        }
-    } else {
-        activeInterface = [self getActiveInterface:upIffList];
-    }
-    if (activeInterface == nil) {
-        *error = [[NSError alloc] initWithDomain:@"iOSLibrary" code:1 userInfo:@{NSLocalizedDescriptionKey: @"bindToDevice: no active interface"}];
-        return @"";
-    }
-    
     unsigned int interfaceIndex = if_nametoindex([activeInterface UTF8String]);
     if (interfaceIndex == 0) {
         *error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"bindToDevice: if_nametoindex failed: %d", errno]}];
@@ -1228,34 +1241,6 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return [NSString stringWithFormat:@"active interface: %@", activeInterface];
 }
 
-/*!
- @brief Returns name of default active network interface from the provided list of active interfaces.
- @param upIffList List of active network interfaces.
- @return Active interface name, nil otherwise.
- @warning Use [NetworkInterface networkPathState:] instead on iOS 12+.
- */
-- (NSString *)getActiveInterface:(NSSet<NSString*>*)upIffList {
-    
-    // TODO: following is a heuristic for choosing active network interface
-    // Only Wi-Fi and Cellular interfaces are considered
-    // @see : https://forums.developer.apple.com/thread/76711
-    NSArray *iffPriorityList = @[@"en0", @"pdp_ip0"];
-    if (atomic_load(&self->currentNetworkStatus) == ReachableViaWWAN) {
-        iffPriorityList = @[@"pdp_ip0", @"en0"];
-    }
-    for (NSString * key in iffPriorityList) {
-        for (NSString * upIff in upIffList) {
-            if ([key isEqualToString:upIff]) {
-                return [NSString stringWithString:upIff];
-            }
-        }
-    }
-    
-    [self logMessage:@"getActiveInterface: No active interface found."];
-    
-    return nil;
-}
-
 - (NSString *)getDNSServersAsString {
     // TODO: Implement correctly
 
@@ -1270,7 +1255,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 
 - (long)hasNetworkConnectivity {
 
-    BOOL hasConnectivity = [self->reachability currentReachabilityStatus] != NotReachable;
+    BOOL hasConnectivity = [self->reachability reachabilityStatus] != NetworkReachabilityNotReachable;
 
     if (!hasConnectivity) {
         // changeConnectionStateTo self-throttles, so even if called multiple
@@ -1291,7 +1276,14 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 }
 
 - (NSString *)getNetworkID {
-    return [NetworkID getNetworkID:[self->reachability currentReachabilityStatus]];
+    NSError *warn;
+    NSString *networkID = [NetworkID getNetworkIDWithReachability:self->reachability
+                                          andCurrentNetworkStatus:atomic_load(&self->currentNetworkStatus)
+                                                          warning:&warn];
+    if (warn != nil) {
+        [self logMessage:[NSString stringWithFormat:@"error getting network ID: %@", warn.localizedDescription]];
+    }
+    return networkID;
 }
 
 - (void)notice:(NSString *)noticeJSON {
@@ -1457,10 +1449,15 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 // time for the tunnel to notice the network is gone (depending on attempts to
 // use the tunnel).
 - (void)startInternetReachabilityMonitoring {
-    atomic_store(&self->currentNetworkStatus, [self->reachability currentReachabilityStatus]);
-
-    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(internetReachabilityChanged:) name:kReachabilityChangedNotification object:nil];
+    // (1) Start notifier and then (2) sample the reachability status to bootstrap current network
+    // status. This ordering is required to ensure we do not miss any reachability status updates.
+    // Note: this function must complete execution before any reachability changed notifications are
+    // processed to ensure ordering; otherwise (2) may overwrite the current network status with a
+    // stale value in the unlikely event where a reachability changed notification is emitted
+    // immediately after (1).
+    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(internetReachabilityChanged:) name:kReachabilityChangedNotification object :nil];
     [self->reachability startNotifier];
+    atomic_store(&self->currentNetworkStatus, [self->reachability reachabilityStatus]);
 }
 
 - (void)stopInternetReachabilityMonitoring {
@@ -1469,33 +1466,55 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
 }
 
 - (void)internetReachabilityChanged:(NSNotification *)note {
+    // Ensure notifications are not processed until current network status is
+    // bootstrapped. See comment in startInternetReachabilityMonitoring.
+    @synchronized (PsiphonTunnel.self) {
+        // 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);
+
+        NetworkReachability networkStatus;
+        NetworkReachability previousNetworkStatus;
+        BOOL interfaceChanged = FALSE;
+
+        // Pass current reachability through to the delegate
+        // as soon as a network reachability change is detected
+        if (@available(iOS 12.0, *)) {
+            ReachabilityChangedNotification *notif = [note object];
+            networkStatus = notif.reachabilityStatus;
+            if (notif.prevDefaultActiveInterfaceName == nil && notif.curDefaultActiveInterfaceName == nil) {
+                // no interface change
+            } else if (notif.prevDefaultActiveInterfaceName == nil || notif.curDefaultActiveInterfaceName == nil) {
+                // interface appeared or disappeared
+                interfaceChanged = TRUE;
+            } else if (![notif.prevDefaultActiveInterfaceName isEqualToString:notif.curDefaultActiveInterfaceName]) {
+                // active interface changed
+                interfaceChanged = TRUE;
+            }
+        } else {
+            Reachability* currentReachability = [note object];
+            networkStatus = [currentReachability reachabilityStatus];
+        }
 
-    // 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);
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(onInternetReachabilityChanged:)]) {
+            dispatch_sync(self->callbackQueue, ^{
+                [self.tunneledAppDelegate onInternetReachabilityChanged:networkStatus];
+            });
+        }
 
-    Reachability* currentReachability = [note object];
+        previousNetworkStatus = atomic_exchange(&self->currentNetworkStatus, networkStatus);
 
-    // Pass current reachability through to the delegate
-    // as soon as a network reachability change is detected
-    if ([self.tunneledAppDelegate respondsToSelector:@selector(onInternetReachabilityChanged:)]) {
-        dispatch_sync(self->callbackQueue, ^{
-            [self.tunneledAppDelegate onInternetReachabilityChanged:currentReachability];
-        });
-    }
-    
-    NetworkStatus networkStatus = [currentReachability currentReachabilityStatus];
-    NetworkStatus previousNetworkStatus = atomic_exchange(&self->currentNetworkStatus, networkStatus);
-    
-    // Restart if the state has changed, unless the previous state was NotReachable, because
-    // the tunnel should be waiting for connectivity in that case.
-    if (networkStatus != previousNetworkStatus && previousNetworkStatus != NotReachable) {
-        GoPsiReconnectTunnel();
+        // Restart if the network status or interface has changed, unless the previous status was
+        // NetworkReachabilityNotReachable, because the tunnel should be waiting for connectivity in
+        // that case.
+        if ((networkStatus != previousNetworkStatus || interfaceChanged) && previousNetworkStatus != NetworkReachabilityNotReachable) {
+            GoPsiReconnectTunnel();
+        }
     }
 }
 
@@ -1740,10 +1759,30 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
             }
         };
 
+        NSDateFormatter *rfc3339Formatter = [PsiphonTunnel rfc3339Formatter];
+
+        void (^logger)(NSString * _Nonnull) = ^void(NSString * _Nonnull msg) {
+            __strong PsiphonTunnelFeedback *strongSelf = weakSelf;
+            if (strongSelf == nil) {
+                return;
+            }
+            __strong id<PsiphonTunnelLoggerDelegate> strongLogger = weakLogger;
+            if (strongLogger == nil) {
+                return;
+            }
+            if ([strongLogger respondsToSelector:@selector(onDiagnosticMessage:withTimestamp:)]) {
+
+                NSString *timestampStr = [rfc3339Formatter stringFromDate:[NSDate date]];
+                dispatch_sync(strongSelf->callbackQueue, ^{
+                    [strongLogger onDiagnosticMessage:msg withTimestamp:timestampStr];
+                });
+            }
+        };
+
         PsiphonProviderNoticeHandlerShim *noticeHandler =
             [[PsiphonProviderNoticeHandlerShim alloc] initWithLogger:logNotice];
 
-        PsiphonProviderNetwork *networkInfoProvider = [[PsiphonProviderNetwork alloc] init];
+        PsiphonProviderNetwork *networkInfoProvider = [[PsiphonProviderNetwork alloc] initWithLogger:logger];
 
         GoPsiStartSendFeedback(
             psiphonConfig,