Browse Source

Add support for selecting additional split tunnel countries

Rod Hynes 4 years ago
parent
commit
19602aa5fa

+ 8 - 3
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -112,7 +112,7 @@ public class PsiphonTunnel {
         default public void onClientRegion(String region) {}
         default public void onClientUpgradeDownloaded(String filename) {}
         default public void onClientIsLatestVersion() {}
-        default public void onSplitTunnelRegion(String region) {}
+        default public void onSplitTunnelRegions(List<String> regions) {}
         default public void onUntunneledAddress(String address) {}
         default public void onBytesTransferred(long sent, long received) {}
         default public void onStartedWaitingForNetworkConnectivity() {}
@@ -929,8 +929,13 @@ public class PsiphonTunnel {
                 mHostService.onHomepage(notice.getJSONObject("data").getString("url"));
             } else if (noticeType.equals("ClientRegion")) {
                 mHostService.onClientRegion(notice.getJSONObject("data").getString("region"));
-            } else if (noticeType.equals("SplitTunnelRegion")) {
-                mHostService.onSplitTunnelRegion(notice.getJSONObject("data").getString("region"));
+            } else if (noticeType.equals("SplitTunnelRegions")) {
+                JSONArray splitTunnelRegions = notice.getJSONObject("data").getJSONArray("regions");
+                ArrayList<String> regions = new ArrayList<String>();
+                for (int i=0; i<splitTunnelRegions.length(); i++) {
+                    regions.add(splitTunnelRegions.getString(i));
+                }
+                mHostService.onSplitTunnelRegions(regions);
             } else if (noticeType.equals("Untunneled")) {
                 mHostService.onUntunneledAddress(notice.getJSONObject("data").getString("address"));
             } else if (noticeType.equals("BytesTransferred")) {

+ 3 - 3
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -227,10 +227,10 @@ WWAN or vice versa or VPN state changed
 - (void)onClientRegion:(NSString * _Nonnull)region;
 
 /*!
- Called to report that split tunnel is on for the given region.
- @param region  The region split tunnel is on for.
+ Called to report that split tunnel is on for the given regions.
+ @param regions  The regions split tunnel is on for.
  */
-- (void)onSplitTunnelRegion:(NSString * _Nonnull)region;
+- (void)onSplitTunnelRegions:(NSArray * _Nonnull)regions;
 
 /*!
  Called to indicate that an address has been classified as being within the

+ 7 - 7
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -994,16 +994,16 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
             });
         }
     }
-    else if ([noticeType isEqualToString:@"SplitTunnelRegion"]) {
-        id region = [notice valueForKeyPath:@"data.region"];
-        if (![region isKindOfClass:[NSString class]]) {
-            [self logMessage:[NSString stringWithFormat: @"SplitTunnelRegion notice missing data.region: %@", noticeJSON]];
+    else if ([noticeType isEqualToString:@"SplitTunnelRegions"]) {
+        id regions = [notice valueForKeyPath:@"data.regions"];
+        if (![regions isKindOfClass:[NSArray class]]) {
+            [self logMessage:[NSString stringWithFormat: @"SplitTunnelRegions notice missing data.regions: %@", noticeJSON]];
             return;
         }
-        
-        if ([self.tunneledAppDelegate respondsToSelector:@selector(onSplitTunnelRegion:)]) {
+
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(onSplitTunnelRegions:)]) {
             dispatch_sync(self->callbackQueue, ^{
-                [self.tunneledAppDelegate onSplitTunnelRegion:region];
+                [self.tunneledAppDelegate onSplitTunnelRegions:regions];
             });
         }
     }

+ 18 - 4
psiphon/config.go

@@ -129,10 +129,18 @@ type Config struct {
 	// in any country is selected.
 	EgressRegion string
 
-	// EnableSplitTunnel toggles split tunnel mode. When enabled, TCP port
-	// forward destinations that resolve to the same GeoIP country as the client
-	// are connected to directly, untunneled.
-	EnableSplitTunnel bool
+	// SplitTunnelOwnRegion enables split tunnel mode for the client's own
+	// country. When enabled, TCP port forward destinations that resolve to
+	// the same GeoIP country as the client are connected to directly,
+	// untunneled.
+	SplitTunnelOwnRegion bool
+
+	// SplitTunnelRegions enables selected split tunnel mode in which the
+	// client specifies a list of ISO 3166-1 alpha-2 country codes for which
+	// traffic should be untunneled. TCP port forwards destined to any
+	// country specified in SplitTunnelRegions will be untunneled, regardless
+	// of whether SplitTunnelOwnRegion is on or off.
+	SplitTunnelRegions []string
 
 	// ListenInterface specifies which interface to listen on.  If no
 	// interface is provided then listen on 127.0.0.1. If 'any' is provided
@@ -1286,6 +1294,12 @@ func (config *Config) GetSponsorID() string {
 	return config.sponsorID
 }
 
+// IsSplitTunnelEnabled indicates if split tunnel mode is enabled, either for
+// the client's own country, a specified list of countries, or both.
+func (config *Config) IsSplitTunnelEnabled() bool {
+	return config.SplitTunnelOwnRegion || len(config.SplitTunnelRegions) > 1
+}
+
 // GetAuthorizations returns the current client authorizations.
 // The caller must not modify the returned slice.
 func (config *Config) GetAuthorizations() []string {

+ 1 - 1
psiphon/controller.go

@@ -1232,7 +1232,7 @@ func (controller *Controller) Dial(
 		return nil, errors.TraceNew("no active tunnels")
 	}
 
-	if !controller.config.EnableSplitTunnel {
+	if !tunnel.config.IsSplitTunnelEnabled() {
 
 		tunneledConn, splitTunnel, err := tunnel.DialTCPChannel(
 			remoteAddr, false, downstreamConn)

+ 7 - 0
psiphon/notice.go

@@ -693,6 +693,13 @@ func NoticeSessionId(sessionId string) {
 		"sessionId", sessionId)
 }
 
+// NoticeSplitTunnelRegions reports that split tunnel is on for the given country codes.
+func NoticeSplitTunnelRegions(regions []string) {
+	singletonNoticeLogger.outputNotice(
+		"SplitTunnelRegions", 0,
+		"regions", regions)
+}
+
 // NoticeUntunneled indicates than an address has been classified as untunneled and is being
 // accessed directly.
 //

+ 24 - 5
psiphon/server/api.go

@@ -212,9 +212,27 @@ func handshakeAPIRequestHandler(
 	// the client, a value of 0 will be used.
 	establishedTunnelsCount, _ := getIntStringRequestParam(params, "established_tunnels_count")
 
-	// splitTunnel indicates if the client is using split tunnel mode. When
-	// omitted by the client, the value will be false.
-	splitTunnel, _ := getBoolStringRequestParam(params, "split_tunnel")
+	// splitTunnelOwnRegion indicates if the client is requesting split tunnel
+	// mode to be applied to the client's own country. When omitted by the
+	// client, the value will be false.
+	//
+	// When split_tunnel_regions is non-empty, split tunnel mode will be
+	// applied for the specified country codes. When omitted by the client,
+	// the value will be an empty slice.
+	splitTunnelOwnRegion, _ := getBoolStringRequestParam(params, "split_tunnel")
+	splitTunnelOtherRegions, _ := getStringArrayRequestParam(params, "split_tunnel_regions")
+
+	ownRegion := ""
+	if splitTunnelOwnRegion {
+		ownRegion = geoIPData.Country
+	}
+	var splitTunnelLookup *splitTunnelLookup
+	if ownRegion != "" || len(splitTunnelOtherRegions) > 0 {
+		splitTunnelLookup, err = newSplitTunnelLookup(ownRegion, splitTunnelOtherRegions)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
 
 	var authorizations []string
 	if params[protocol.PSIPHON_API_HANDSHAKE_AUTHORIZATIONS] != nil {
@@ -243,7 +261,7 @@ func handshakeAPIRequestHandler(
 			apiParams:               copyBaseSessionAndDialParams(params),
 			expectDomainBytes:       len(httpsRequestRegexes) > 0,
 			establishedTunnelsCount: establishedTunnelsCount,
-			splitTunnel:             splitTunnel,
+			splitTunnelLookup:       splitTunnelLookup,
 		},
 		authorizations)
 	if err != nil {
@@ -890,6 +908,7 @@ var baseDialParams = []requestParamSpec{
 	{"conjure_delay", isIntString, requestParamOptional | requestParamLogStringAsInt},
 	{"conjure_transport", isAnyString, requestParamOptional},
 	{"split_tunnel", isBooleanFlag, requestParamOptional | requestParamLogFlagAsBool},
+	{"split_tunnel_regions", isRegionCode, requestParamOptional | requestParamArray},
 }
 
 // baseSessionAndDialParams adds baseDialParams to baseSessionParams.
@@ -989,7 +1008,7 @@ func validateStringArrayRequestParam(
 
 	arrayValue, ok := value.([]interface{})
 	if !ok {
-		return errors.Tracef("unexpected string param type: %s", expectedParam.name)
+		return errors.Tracef("unexpected array param type: %s", expectedParam.name)
 	}
 	for _, value := range arrayValue {
 		err := validateStringRequestParam(config, expectedParam, value)

+ 1 - 1
psiphon/server/server_test.go

@@ -1009,7 +1009,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	clientConfig.EmitServerAlerts = true
 
 	if runConfig.doSplitTunnel {
-		clientConfig.EnableSplitTunnel = true
+		clientConfig.SplitTunnelOwnRegion = true
 	}
 
 	if !runConfig.omitAuthorization {

+ 69 - 9
psiphon/server/tunnelServer.go

@@ -1376,6 +1376,13 @@ func (q *qualityMetrics) reset() {
 	}
 }
 
+type handshakeStateInfo struct {
+	activeAuthorizationIDs   []string
+	authorizedAccessTypes    []string
+	upstreamBytesPerSecond   int64
+	downstreamBytesPerSecond int64
+}
+
 type handshakeState struct {
 	completed               bool
 	apiProtocol             string
@@ -1385,14 +1392,64 @@ type handshakeState struct {
 	authorizationsRevoked   bool
 	expectDomainBytes       bool
 	establishedTunnelsCount int
-	splitTunnel             bool
+	splitTunnelLookup       *splitTunnelLookup
 }
 
-type handshakeStateInfo struct {
-	activeAuthorizationIDs   []string
-	authorizedAccessTypes    []string
-	upstreamBytesPerSecond   int64
-	downstreamBytesPerSecond int64
+type splitTunnelLookup struct {
+	regions       []string
+	regionsLookup map[string]bool
+}
+
+func newSplitTunnelLookup(
+	ownRegion string,
+	otherRegions []string) (*splitTunnelLookup, error) {
+
+	length := len(otherRegions)
+	if ownRegion != "" {
+		length += 1
+	}
+
+	// This length check is a sanity check and prevents clients shipping
+	// excessively long lists which could impact performance.
+	if length > 250 {
+		return nil, errors.Tracef("too many regions: %d", length)
+	}
+
+	// Create map lookups for lists where the number of values to compare
+	// against exceeds a threshold where benchmarks show maps are faster than
+	// looping through a slice. Otherwise use a slice for lookups. In both
+	// cases, the input slice is no longer referenced.
+
+	if length >= stringLookupThreshold {
+		regionsLookup := make(map[string]bool)
+		if ownRegion != "" {
+			regionsLookup[ownRegion] = true
+		}
+		for _, region := range otherRegions {
+			regionsLookup[region] = true
+		}
+		return &splitTunnelLookup{
+			regionsLookup: regionsLookup,
+		}, nil
+	} else {
+		regions := []string{}
+		if ownRegion != "" && !common.Contains(otherRegions, ownRegion) {
+			regions = append(regions, ownRegion)
+		}
+		// TODO: check for other duplicate regions?
+		regions = append(regions, otherRegions...)
+		return &splitTunnelLookup{
+			regions: regions,
+		}, nil
+	}
+}
+
+func (lookup *splitTunnelLookup) lookup(region string) bool {
+	if lookup.regionsLookup != nil {
+		return lookup.regionsLookup[region]
+	} else {
+		return common.Contains(lookup.regions, region)
+	}
 }
 
 func newSshClient(
@@ -2520,11 +2577,13 @@ func (sshClient *sshClient) handleNewTCPPortForwardChannel(
 		// Split tunnel logic is enabled for this TCP port forward when the client
 		// has enabled split tunnel mode and the channel type allows it.
 
+		doSplitTunnel := sshClient.handshakeState.splitTunnelLookup != nil && allowSplitTunnel
+
 		tcpPortForward := &newTCPPortForward{
 			enqueueTime:   time.Now(),
 			hostToConnect: directTcpipExtraData.HostToConnect,
 			portToConnect: int(directTcpipExtraData.PortToConnect),
-			doSplitTunnel: sshClient.handshakeState.splitTunnel && allowSplitTunnel,
+			doSplitTunnel: doSplitTunnel,
 			newChannel:    newChannel,
 		}
 
@@ -3816,8 +3875,9 @@ func (sshClient *sshClient) handleTCPChannel(
 
 		destinationGeoIPData := sshClient.sshServer.support.GeoIPService.LookupIP(IP)
 
-		if destinationGeoIPData.Country == sshClient.geoIPData.Country &&
-			sshClient.geoIPData.Country != GEOIP_UNKNOWN_VALUE {
+		if sshClient.geoIPData.Country != GEOIP_UNKNOWN_VALUE &&
+			sshClient.handshakeState.splitTunnelLookup.lookup(
+				destinationGeoIPData.Country) {
 
 			// Since isPortForwardPermitted is not called in this case, explicitly call
 			// ipBlocklistCheck. The domain blocklist case is handled above.

+ 28 - 1
psiphon/serverApi.go

@@ -165,10 +165,18 @@ func (serverContext *ServerContext) doHandshakeRequest(
 	// indicated, the server will perform split tunnel classifications on TCP
 	// port forwards and reject, with a distinct response, port forwards which
 	// the client should connect to directly, untunneled.
-	if serverContext.tunnel.config.EnableSplitTunnel {
+	if serverContext.tunnel.config.SplitTunnelOwnRegion {
 		params["split_tunnel"] = "1"
 	}
 
+	// While regular split tunnel mode makes untunneled connections to
+	// destinations in the client's own country, selected split tunnel mode
+	// allows the client to specify a list of untunneled countries. Either or
+	// both modes may be enabled.
+	if len(serverContext.tunnel.config.SplitTunnelRegions) > 0 {
+		params["split_tunnel_regions"] = serverContext.tunnel.config.SplitTunnelRegions
+	}
+
 	var response []byte
 	if serverContext.psiphonHttpsClient == nil {
 
@@ -230,6 +238,25 @@ func (serverContext *ServerContext) doHandshakeRequest(
 
 	NoticeClientRegion(handshakeResponse.ClientRegion)
 
+	// Emit a SplitTunnelRegions notice indicating active split tunnel region.
+	// For SplitTunnelOwnRegion, the handshake ClientRegion is the split
+	// tunnel region and this region is always listed first.
+
+	splitTunnelRegions := []string{}
+	if serverContext.tunnel.config.SplitTunnelOwnRegion {
+		splitTunnelRegions = []string{handshakeResponse.ClientRegion}
+	}
+	for _, region := range serverContext.tunnel.config.SplitTunnelRegions {
+		if !serverContext.tunnel.config.SplitTunnelOwnRegion ||
+			region != handshakeResponse.ClientRegion {
+
+			splitTunnelRegions = append(splitTunnelRegions, region)
+		}
+	}
+	if len(splitTunnelRegions) > 0 {
+		NoticeSplitTunnelRegions(splitTunnelRegions)
+	}
+
 	var serverEntries []protocol.ServerEntryFields
 
 	// Store discovered server entries

+ 1 - 1
psiphon/tunnel.go

@@ -452,7 +452,7 @@ func (tunnel *Tunnel) DialTCPChannel(
 	downstreamConn net.Conn) (net.Conn, bool, error) {
 
 	channelType := "direct-tcpip"
-	if alwaysTunneled && tunnel.config.EnableSplitTunnel {
+	if alwaysTunneled && tunnel.config.IsSplitTunnelEnabled() {
 		// This channel type is only necessary in split tunnel mode.
 		channelType = protocol.TCP_PORT_FORWARD_NO_SPLIT_TUNNEL_TYPE
 	}