outline-apps icon indicating copy to clipboard operation
outline-apps copied to clipboard

Support white list

Open qutang opened this issue 6 years ago • 4 comments

It would be nice if Outline can support white list when directing incoming traffic. This probably can be done by user on the client side with a default white list managed by server administrator.

URLs/IPs on the white list will be connected directly from the client bypassing the VPN server.

This is useful because sometimes we have to switch VPN on and off in order to visit different websites.

qutang avatar Sep 09 '19 22:09 qutang

We had a go at this. Here is a patch, which you are welcome to use as you wish.

We found we had to set a whitelist for Android, and a blacklist for iOS (which we did from outside the plugin). We didn't try to support any other platforms.

I guess we focused on IPv4 and ignored IPv6. Sorry, but at least this may be a pointer in the right direction!

diff -u -r ./cordova-plugin-outline/android/java/org/outline/OutlinePlugin.java /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/android/java/org/outline/OutlinePlugin.java
--- ./cordova-plugin-outline/android/java/org/outline/OutlinePlugin.java	2019-03-04 10:58:07.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/android/java/org/outline/OutlinePlugin.java	2019-12-09 13:49:30.000000000 +0800
@@ -25,11 +25,13 @@
 import android.os.IBinder;
 import android.support.v4.content.LocalBroadcastManager;
 import android.util.Pair;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -48,6 +50,7 @@
 
   // Actions supported by this plugin.
   public enum Action {
+    SET_IP_WHITELIST("setIPWhitelist"),
     START("start"),
     STOP("stop"),
     ON_STATUS_CHANGE("onStatusChange"),
@@ -129,13 +132,14 @@
   private static final int REQUEST_CODE_PREPARE_VPN = 100;
   private static final int RESULT_OK = -1; // Standard activity result: operation succeeded.
   private static final HashSet<String> CONNECTION_INSTANCE_ACTIONS =
-      new HashSet<String>(Arrays.asList(Action.START.value, Action.STOP.value,
+      new HashSet<String>(Arrays.asList(Action.SET_IP_WHITELIST.value, Action.START.value, Action.STOP.value,
           Action.IS_RUNNING.value, Action.ON_STATUS_CHANGE.value, Action.IS_REACHABLE.value));
 
   private VpnTunnelService vpnTunnelService = null;
   private String startRequestConnectionId = null;
   private JSONObject startRequestConfig = null;
   private Map<Pair<String, String>, CallbackContext> listeners = new ConcurrentHashMap();
+  private List<String> ipWhitelist = null;
 
   // Class to bind to VpnTunnelService.
   private ServiceConnection serviceConnection =
@@ -157,6 +161,7 @@
 
     Context context = getBaseContext();
     IntentFilter broadcastFilter = new IntentFilter();
+    broadcastFilter.addAction(Action.SET_IP_WHITELIST.value);
     broadcastFilter.addAction(Action.START.value);
     broadcastFilter.addAction(Action.STOP.value);
     broadcastFilter.addAction(Action.ON_STATUS_CHANGE.value);
@@ -214,7 +219,15 @@
               public void run() {
                 try {
                   // Connection instance actions
-                  if (Action.START.is(action)) {
+                  if (Action.SET_IP_WHITELIST.is(action)) {
+                    //LOG.debug("[OutlinePlugin.java] Received whitelist with length " + args.getJSONArray(1).length() + "");
+                    JSONArray jsonWhitelist = args.getJSONArray(1);
+                    ArrayList<String> _ipWhitelist = new ArrayList<String>();
+                    for (int i = 0; i < jsonWhitelist.length(); i++) {
+                      _ipWhitelist.add(jsonWhitelist.getString(i));
+                    }
+                    ipWhitelist = _ipWhitelist;
+                  } else if (Action.START.is(action)) {
                     // Set instance variables in case we need to start the VPN service from
                     // onActivityResult
                     startRequestConnectionId = connectionId;
@@ -307,6 +320,7 @@
       onVpnTunnelServiceNotBound(Action.START, startRequestConnectionId);
       return;
     }
+    vpnTunnelService.setIPWhitelist(ipWhitelist);
     vpnTunnelService.startConnection(startRequestConnectionId, startRequestConfig);
   }
 
diff -u -r ./cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnel.java /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnel.java
--- ./cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnel.java	2019-03-04 10:58:07.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnel.java	2020-01-08 10:54:04.000000000 +0800
@@ -21,6 +21,7 @@
 import android.net.VpnService;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Locale;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -53,6 +54,7 @@
   private String dnsResolverAddress;
   private ParcelFileDescriptor tunFd;
   private Thread tun2socksThread = null;
+  private List<String> ipWhitelist = null;
 
   /**
    * Constructor.
@@ -67,6 +69,10 @@
     this.vpnService = vpnService;
   }
 
+  public void setIPWhitelist(List<String> _ipWhitelist) {
+    ipWhitelist = _ipWhitelist;
+  }
+
   /**
    * Establishes a system-wide VPN that routes all device traffic to its TUN interface. Randomly
    * selects between OpenDNS and Dyn resolvers to set the VPN's DNS resolvers.
@@ -93,8 +99,10 @@
       }
       // In absence of an API to remove routes, instead of adding the default route (0.0.0.0/0),
       // retrieve the list of subnets that excludes those reserved for special use.
-      final ArrayList<Subnet> reservedBypassSubnets = getReservedBypassSubnets();
+      //final ArrayList<Subnet> reservedBypassSubnets = getReservedBypassSubnets();
+      final ArrayList<Subnet> reservedBypassSubnets = getIPWhitelistSubnets();
       for (Subnet subnet : reservedBypassSubnets) {
+        LOG.info("Adding whitelist route: " + subnet.address + "/" + subnet.prefix);
         builder.addRoute(subnet.address, subnet.prefix);
       }
       tunFd = builder.establish();
@@ -197,6 +205,20 @@
     return subnets;
   }
 
+  /* Returns a list of whitelisted IP ranges. */
+  private ArrayList<Subnet> getIPWhitelistSubnets() {
+    final List<String> subnetStrings = ipWhitelist;
+    ArrayList<Subnet> subnets = new ArrayList<>(subnetStrings.size());
+    for (final String subnetString : subnetStrings) {
+      try {
+        subnets.add(Subnet.parse(subnetString));
+      } catch (Exception e) {
+        LOG.warning(String.format(Locale.ROOT, "Failed to parse subnet: %s", subnetString));
+      }
+    }
+    return subnets;
+  }
+
   /* Represents an IP subnet. */
   private static class Subnet {
     public String address;
diff -u -r ./cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnelService.java /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnelService.java
--- ./cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnelService.java	2019-12-09 11:27:21.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/android/java/org/outline/vpn/VpnTunnelService.java	2020-01-08 10:54:04.000000000 +0800
@@ -38,6 +38,7 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.ThreadPoolExecutor;
+import java.util.List;
 import java.util.Locale;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -69,6 +70,7 @@
   private NetworkConnectivityMonitor networkConnectivityMonitor;
   private VpnConnectionStore connectionStore;
   private Notification.Builder notificationBuilder;
+  private List<String> ipWhitelist = null;
 
   public class LocalBinder extends Binder {
     public VpnTunnelService getService() {
@@ -130,6 +132,10 @@
 
   // Connection API
 
+  public void setIPWhitelist(List<String> _ipWhitelist) {
+    ipWhitelist = _ipWhitelist;
+  }
+
   /**
    * Establishes a system-wide VPN connected to a remote Shadowsocks server. All device traffic is
    * routed as follows: |VPN TUN interface| <-> |tun2socks| <-> |local Shadowsocks server| <->
@@ -182,6 +188,7 @@
       vpnTunnel.disconnectTunnel();
     } else {
       // Only establish the VPN if this is not a connection restart.
+      vpnTunnel.setIPWhitelist(ipWhitelist);
       if (!vpnTunnel.establishVpn()) {
         LOG.severe("Failed to establish the VPN");
         onVpnStartFailure(OutlinePlugin.ErrorCode.VPN_START_FAILURE);
diff -u -r ./cordova-plugin-outline/apple/src/OutlinePlugin.swift /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/src/OutlinePlugin.swift
--- ./cordova-plugin-outline/apple/src/OutlinePlugin.swift	2019-01-18 10:54:31.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/src/OutlinePlugin.swift	2019-12-09 13:49:30.000000000 +0800
@@ -21,6 +21,8 @@
 class OutlinePlugin: CDVPlugin {
 
   private enum Action {
+    static let setIPWhitelist = "setIPWhitelist"
+    static let setIPBlacklist = "setIPBlacklist"
     static let start = "start"
     static let stop = "stop"
     static let onStatusChange = "onStatusChange"
@@ -60,6 +62,24 @@
     #endif
   }
 
+  func setIPWhitelist(_ command: CDVInvokedUrlCommand) {
+    DDLogInfo("[OutlinePlugin.swift] Receiving ipWhitelist")
+    guard let ipWhitelist = command.argument(at: 1) as? [String] else {
+      return sendError("Missing ipWhitelist", callbackId: command.callbackId)
+    }
+    OutlineVpn.shared.setIPWhitelist(ipWhitelist)
+    self.sendSuccess(callbackId: command.callbackId)
+  }
+
+  func setIPBlacklist(_ command: CDVInvokedUrlCommand) {
+    DDLogInfo("[OutlinePlugin.swift] Receiving ipBlacklist")
+    guard let ipBlacklist = command.argument(at: 1) as? [String] else {
+      return sendError("Missing ipBlacklist", callbackId: command.callbackId)
+    }
+    OutlineVpn.shared.setIPBlacklist(ipBlacklist)
+    self.sendSuccess(callbackId: command.callbackId)
+  }
+
   /**
    Starts the VPN. This method is idempotent for a given connection.
    - Parameters:
diff -u -r ./cordova-plugin-outline/apple/src/OutlineVpn.swift /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/src/OutlineVpn.swift
--- ./cordova-plugin-outline/apple/src/OutlineVpn.swift	2019-03-04 10:58:07.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/src/OutlineVpn.swift	2019-12-09 13:49:30.000000000 +0800
@@ -31,6 +31,8 @@
   private let connectivity: OutlineConnectivity
 
   private enum Action {
+    static let setIPWhitelist = "setIPWhitelist"
+    static let setIPBlacklist = "setIPBlacklist"
     static let start = "start"
     static let restart = "restart"
     static let stop = "stop"
@@ -43,6 +45,8 @@
     static let connectionId = "connectionId"
     static let config = "config"
     static let errorCode = "errorCode"
+    static let ipWhitelist = "ipWhitelist"
+    static let ipBlacklist = "ipBlacklist"
     static let host = "host"
     static let port = "port"
     static let isOnDemand = "is-on-demand"
@@ -86,6 +90,34 @@
 
   // MARK: Interface
 
+  func setIPWhitelist(_ ipWhitelist: [String]) {
+    DDLogInfo("[OutlineVpn.swift] Receiving ipWhitelist")
+    // Pass the list to PacketTunnelProvider
+    let message = [MessageKey.action: Action.setIPWhitelist,
+                   MessageKey.ipWhitelist: ipWhitelist] as [String : Any]
+    sendVpnExtensionMessage(message) { response in
+      guard response == nil else {
+        DDLogInfo("[OutlineVpn.swift] Problem passing ipWhitelist: \(String(describing: response))")
+        return
+      }
+      DDLogInfo("[OutlineVpn.swift] Successfully passed ipWhitelist")
+    }
+  }
+
+  func setIPBlacklist(_ ipBlacklist: [String]) {
+    DDLogInfo("[OutlineVpn.swift] Receiving ipBlacklist")
+    // Pass the list to PacketTunnelProvider
+    let message = [MessageKey.action: Action.setIPBlacklist,
+                   MessageKey.ipBlacklist: ipBlacklist] as [String : Any]
+    sendVpnExtensionMessage(message) { response in
+      guard response == nil else {
+        DDLogInfo("[OutlineVpn.swift] Problem passing ipBlacklist: \(String(describing: response))")
+        return
+      }
+      DDLogInfo("[OutlineVpn.swift] Successfully passed ipBlacklist")
+    }
+  }
+
   // Starts a VPN connection as specified in the OutlineConnection object.
   func start(_ connection: OutlineConnection, _ completion: @escaping (Callback)) {
     guard let connectionId = connection.id else {
diff -u -r ./cordova-plugin-outline/apple/vpn/PacketTunnelProvider.m /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/vpn/PacketTunnelProvider.m
--- ./cordova-plugin-outline/apple/vpn/PacketTunnelProvider.m	2019-05-02 13:27:14.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/vpn/PacketTunnelProvider.m	2020-01-08 10:54:05.000000000 +0800
@@ -31,16 +31,22 @@
 NSString *const kActionRestart = @"restart";
 NSString *const kActionStop = @"stop";
 NSString *const kActionGetConnectionId = @"getConnectionId";
+NSString *const kActionSetIPWhitelist = @"setIPWhitelist";
+NSString *const kActionSetIPBlacklist = @"setIPBlacklist";
 NSString *const kActionIsReachable = @"isReachable";
 NSString *const kMessageKeyAction = @"action";
 NSString *const kMessageKeyConnectionId = @"connectionId";
 NSString *const kMessageKeyConfig = @"config";
 NSString *const kMessageKeyErrorCode = @"errorCode";
+NSString *const kMessageKeyIPWhitelist = @"ipWhitelist";
+NSString *const kMessageKeyIPBlacklist = @"ipBlacklist";
 NSString *const kMessageKeyHost = @"host";
 NSString *const kMessageKeyPort = @"port";
 NSString *const kMessageKeyOnDemand = @"is-on-demand";
 NSString *const kDefaultPathKey = @"defaultPath";
 static NSDictionary *kVpnSubnetCandidates;  // Subnets to bind the VPN.
+static NSArray *ipWhitelist;
+static NSArray *ipBlacklist;
 
 @interface PacketTunnelProvider()
 @property (nonatomic) Shadowsocks *shadowsocks;
@@ -250,6 +256,16 @@
                                                  options:kNilOptions error:nil];
     }
     completionHandler(response);
+  } else if ([kActionSetIPWhitelist isEqualToString:action]) {
+    DDLogInfo(@"[PacketTunnelProvider.m] receiving ipWhitelist");
+    NSArray *ipWhitelistIn = message[kMessageKeyIPWhitelist];
+    ipWhitelist = ipWhitelistIn;
+    completionHandler(nil);
+  } else if ([kActionSetIPBlacklist isEqualToString:action]) {
+    DDLogInfo(@"[PacketTunnelProvider.m] receiving ipBlacklist");
+    NSArray *ipBlacklistIn = message[kMessageKeyIPBlacklist];
+    ipBlacklist = ipBlacklistIn;
+    completionHandler(nil);
   } else if ([kActionIsReachable isEqualToString:action]) {
     NSString *host = message[kMessageKeyHost];
     NSNumber *port = message[kMessageKeyPort];
@@ -312,8 +328,14 @@
   NSString *vpnAddress = [self selectVpnAddress];
   NEIPv4Settings *ipv4Settings =
       [[NEIPv4Settings alloc] initWithAddresses:@[ vpnAddress ] subnetMasks:@[ @"255.255.255.0" ]];
+  // Original code:
+  //ipv4Settings.includedRoutes = @[[NEIPv4Route defaultRoute]];
+  //ipv4Settings.excludedRoutes = [self getExcludedIpv4Routes];
+  // Our modifications:
+  // No matter what we set for includedRoutes, it seems to include every address which is not excluded!
+  // Therefore we include everything, but exclude addresses we don't want.
   ipv4Settings.includedRoutes = @[[NEIPv4Route defaultRoute]];
-  ipv4Settings.excludedRoutes = [self getExcludedIpv4Routes];
+  ipv4Settings.excludedRoutes = [self getBlacklistedIpv4Routes];
 
   // Although we don't support proxying IPv6 traffic, we need to set IPv6 routes so that the DNS
   // settings are respected on IPv6-only networks. Bind to a random unique local address (ULA).
@@ -334,14 +356,37 @@
 
 - (NSArray *)getExcludedIpv4Routes {
   NSMutableArray *excludedIpv4Routes = [[NSMutableArray alloc] init];
+  DDLogInfo(@"[PacketTunnelProvider.m] Getting excluded routes");
   for (Subnet *subnet in [Subnet getReservedSubnets]) {
-    NEIPv4Route *route =
-        [[NEIPv4Route alloc] initWithDestinationAddress:subnet.address subnetMask:subnet.mask];
-    [excludedIpv4Routes addObject:route];
+    [excludedIpv4Routes addObject:[self getRouteFromSubnet:subnet]];
   }
   return excludedIpv4Routes;
 }
 
+- (NSArray *)getWhitelistedIpv4Routes {
+  NSMutableArray *whitelistedRoutes = [[NSMutableArray alloc] init];
+  DDLogInfo(@"[PacketTunnelProvider.m] Getting whitelisted routes");
+  for (Subnet *subnet in [Subnet buildSubnetsList:ipWhitelist]) {
+    [whitelistedRoutes addObject:[self getRouteFromSubnet:subnet]];
+  }
+  return whitelistedRoutes;
+}
+
+- (NSArray *)getBlacklistedIpv4Routes {
+  NSMutableArray *blacklistedRoutes = [[NSMutableArray alloc] init];
+  DDLogInfo(@"[PacketTunnelProvider.m] Getting blacklisted routes");
+  for (Subnet *subnet in [Subnet buildSubnetsList:ipBlacklist]) {
+    [blacklistedRoutes addObject:[self getRouteFromSubnet:subnet]];
+  }
+  return blacklistedRoutes;
+}
+
+- (NEIPv4Route *)getRouteFromSubnet:(Subnet *)subnet {
+  NEIPv4Route *route =
+      [[NEIPv4Route alloc] initWithDestinationAddress:subnet.address subnetMask:subnet.mask];
+  return route;
+}
+
 - (void)observeValueForKeyPath:(nullable NSString *)keyPath
                       ofObject:(nullable id)object
                         change:(nullable NSDictionary<NSString *, id> *)change
diff -u -r ./cordova-plugin-outline/apple/vpn/Subnet.swift /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/vpn/Subnet.swift
--- ./cordova-plugin-outline/apple/vpn/Subnet.swift	2019-03-04 10:58:07.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/apple/vpn/Subnet.swift	2019-12-09 13:49:30.000000000 +0800
@@ -60,6 +60,16 @@
     return subnets
   }
 
+  static func buildSubnetsList(_ stringArray: NSArray) -> [Subnet] {
+    var subnets: [Subnet] = []
+    for cidrSubnet in stringArray {
+      if let subnet = self.parse(cidrSubnet as! NSString as String) {
+        subnets.append(subnet)
+      }
+    }
+    return subnets
+  }
+
   public var address: String
   public var prefix: UInt16
   public var mask: String
diff -u -r ./cordova-plugin-outline/outlinePlugin.js /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/outlinePlugin.js
--- ./cordova-plugin-outline/outlinePlugin.js	2019-01-18 10:54:31.000000000 +0800
+++ /home/joey/outline-client-with-whitelisting/cordova-plugin-outline/outlinePlugin.js	2019-12-09 13:49:30.000000000 +0800
@@ -97,6 +97,16 @@
   exec(success, error, PLUGIN_NAME, cmd, [this.id_].concat(args));
 };
 
+// Not used on iOS
+Connection.prototype.setIPWhitelist = function(whitelist) {
+  return this._promiseExec('setIPWhitelist', [whitelist]);
+};
+
+// Only used on iOS
+Connection.prototype.setIPBlacklist = function(blacklist) {
+  return this._promiseExec('setIPBlacklist', [blacklist]);
+};
+
 Connection.prototype.start = function() {
   return this._promiseExec('start', [this.config]);
 };

Related (possibly): #556 #624 #582

joeysino avatar Jan 29 '20 08:01 joeysino

Here is a pull request for the above patch: https://github.com/Jigsaw-Code/outline-client/compare/master...joeysino:ip-whitelist?expand=1

I hurriedly extracted this code from our project, so please test it before merging!

Usage:

const whitelist = [ '12.7.44.0/24', '34.22.0.0/16' ];

if (device.platform === 'Android') {
  // Android accepts a whitelist
  server.setIPWhitelist(whitelist);
} else if (devlice.platform === 'iOS') {
  // But iOS cannot accept a whitelist, it must be passed a blacklist
  const blacklist = cidrTools.exclude('0.0.0.0/0', whitelist);
  server.setIPBlacklist(blacklist);
}

// Then do
server.connect();

Perhaps in future, both functions could work on both OSes, by inverting the CIDR list internally when necessary.

joeysino avatar Jan 30 '20 03:01 joeysino

My .02, we've talked about selective proxying features like this. We probably want to think about what we want UX to be for something like this.

JonathanDCohen avatar Jan 31 '20 20:01 JonathanDCohen

Need help?

ShadyRover avatar Mar 29 '22 17:03 ShadyRover

Any updates?

RuBAN-GT avatar Sep 29 '23 08:09 RuBAN-GT

Closing as duplicate of #887

maddyhof avatar Oct 16 '23 17:10 maddyhof