/* * Copyright (c) 2015, Psiphon Inc. * All rights reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * */ package ca.psiphon; import android.annotation.TargetApi; import android.content.Context; import android.net.ConnectivityManager; import android.net.LinkProperties; import android.net.Network; import android.net.NetworkCapabilities; import android.net.NetworkInfo; import android.net.NetworkRequest; import android.net.VpnService; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; import android.os.Build; import android.os.ParcelFileDescriptor; import android.telephony.TelephonyManager; import android.text.TextUtils; import android.util.Base64; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.NetworkInterface; import java.net.SocketException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.Executors; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import psi.Psi; import psi.PsiphonProvider; import psi.PsiphonProviderNetwork; import psi.PsiphonProviderNoticeHandler; import psi.PsiphonProviderFeedbackHandler; public class PsiphonTunnel { public interface HostLogger { default public void onDiagnosticMessage(String message) {} } // Protocol used to communicate the outcome of feedback upload operations to the application // using PsiphonTunnelFeedback. public interface HostFeedbackHandler { // Callback which is invoked once the feedback upload has completed. // If the exception is non-null, then the upload failed. default public void sendFeedbackCompleted(java.lang.Exception e) {} } public interface HostLibraryLoader { default public void loadLibrary(String library) { System.loadLibrary(library); } } public interface HostService extends HostLogger, HostLibraryLoader { public String getAppName(); public Context getContext(); public String getPsiphonConfig(); default public Object getVpnService() {return null;} // Object must be a VpnService (Android < 4 cannot reference this class name) default public Object newVpnServiceBuilder() {return null;} // Object must be a VpnService.Builder (Android < 4 cannot reference this class name) default public void onAvailableEgressRegions(List regions) {} default public void onSocksProxyPortInUse(int port) {} default public void onHttpProxyPortInUse(int port) {} default public void onListeningSocksProxyPort(int port) {} default public void onListeningHttpProxyPort(int port) {} default public void onUpstreamProxyError(String message) {} default public void onConnecting() {} default public void onConnected() {} default public void onHomepage(String url) {} default public void onClientRegion(String region) {} default public void onClientAddress(String address) {} default public void onClientUpgradeDownloaded(String filename) {} default public void onClientIsLatestVersion() {} default public void onSplitTunnelRegions(List regions) {} default public void onUntunneledAddress(String address) {} default public void onBytesTransferred(long sent, long received) {} default public void onStartedWaitingForNetworkConnectivity() {} default public void onStoppedWaitingForNetworkConnectivity() {} default public void onActiveAuthorizationIDs(List authorizations) {} default public void onTrafficRateLimits(long upstreamBytesPerSecond, long downstreamBytesPerSecond) {} default public void onApplicationParameters(Object parameters) {} default public void onServerAlert(String reason, String subject, List actionURLs) {} default public void onExiting() {} } private final HostService mHostService; private AtomicBoolean mVpnMode; private PrivateAddress mPrivateAddress; private AtomicReference mTunFd; private AtomicInteger mLocalSocksProxyPort; private AtomicBoolean mRoutingThroughTunnel; private Thread mTun2SocksThread; private AtomicBoolean mIsWaitingForNetworkConnectivity; private AtomicReference mClientPlatformPrefix; private AtomicReference mClientPlatformSuffix; private final boolean mShouldRouteThroughTunnelAutomatically; private final NetworkMonitor mNetworkMonitor; private AtomicReference mActiveNetworkType; private AtomicReference mActiveNetworkDNSServers; // Only one PsiphonVpn instance may exist at a time, as the underlying // psi.Psi and tun2socks implementations each contain global state. private static PsiphonTunnel mPsiphonTunnel; public static synchronized PsiphonTunnel newPsiphonTunnel(HostService hostService) { return newPsiphonTunnelImpl(hostService, true); } // The two argument override in case the host app wants to take control over calling routeThroughTunnel() public static synchronized PsiphonTunnel newPsiphonTunnel(HostService hostService, boolean shouldRouteThroughTunnelAutomatically) { return newPsiphonTunnelImpl(hostService, shouldRouteThroughTunnelAutomatically); } private static PsiphonTunnel newPsiphonTunnelImpl(HostService hostService, boolean shouldRouteThroughTunnelAutomatically) { if (mPsiphonTunnel != null) { mPsiphonTunnel.stop(); } // Load the native go code embedded in psi.aar hostService.loadLibrary("gojni"); mPsiphonTunnel = new PsiphonTunnel(hostService, shouldRouteThroughTunnelAutomatically); return mPsiphonTunnel; } // Returns default path where upgrade downloads will be paved. Only applicable if // DataRootDirectory was not set in the outer config. If DataRootDirectory was set in the // outer config, use getUpgradeDownloadFilePath with its value instead. public static String getDefaultUpgradeDownloadFilePath(Context context) { return Psi.upgradeDownloadFilePath(defaultDataRootDirectory(context).getAbsolutePath()); } // Returns the path where upgrade downloads will be paved relative to the configured // DataRootDirectory. public static String getUpgradeDownloadFilePath(String dataRootDirectoryPath) { return Psi.upgradeDownloadFilePath(dataRootDirectoryPath); } private static File defaultDataRootDirectory(Context context) { return context.getFileStreamPath("ca.psiphon.PsiphonTunnel.tunnel-core"); } private PsiphonTunnel(HostService hostService, boolean shouldRouteThroughTunnelAutomatically) { mHostService = hostService; mVpnMode = new AtomicBoolean(false); mTunFd = new AtomicReference(); mLocalSocksProxyPort = new AtomicInteger(0); mRoutingThroughTunnel = new AtomicBoolean(false); mIsWaitingForNetworkConnectivity = new AtomicBoolean(false); mClientPlatformPrefix = new AtomicReference(""); mClientPlatformSuffix = new AtomicReference(""); mShouldRouteThroughTunnelAutomatically = shouldRouteThroughTunnelAutomatically; mActiveNetworkType = new AtomicReference(""); mActiveNetworkDNSServers = new AtomicReference(""); mNetworkMonitor = new NetworkMonitor(new NetworkMonitor.NetworkChangeListener() { @Override public void onChanged() { try { reconnectPsiphon(); } catch (Exception e) { mHostService.onDiagnosticMessage("reconnect error: " + e); } } }); } public Object clone() throws CloneNotSupportedException { throw new CloneNotSupportedException(); } //---------------------------------------------------------------------------------------------- // Public API //---------------------------------------------------------------------------------------------- // To start, call in sequence: startRouting(), then startTunneling(). After startRouting() // succeeds, the caller must call stop() to clean up. These functions should not be called // concurrently. Do not call stop() while startRouting() or startTunneling() is in progress. // In case the host application requests manual control of routing through tunnel by calling // PsiphonTunnel.newPsiphonTunnel(HostService hostservice, shouldRouteThroughTunnelAutomatically = false) // it should also call routeThroughTunnel() at some point, usually after receiving onConnected() callback, // otherwise it will be called automatically. // Returns true when the VPN routing is established; returns false if the VPN could not // be started due to lack of prepare or revoked permissions (called should re-prepare and // try again); throws exception for other error conditions. public synchronized boolean startRouting() throws Exception { // Load tun2socks library embedded in the aar // If this method is called more than once with the same library name, the second and subsequent calls are ignored. // http://docs.oracle.com/javase/7/docs/api/java/lang/Runtime.html#loadLibrary%28java.lang.String%29 mHostService.loadLibrary("tun2socks"); return startVpn(); } // Starts routing traffic via tunnel by starting tun2socks if it is not running already. // This will be called automatically right after tunnel gets connected in case the host application // did not request a manual control over this functionality, see PsiphonTunnel.newPsiphonTunnel public void routeThroughTunnel() { if (!mRoutingThroughTunnel.compareAndSet(false, true)) { return; } ParcelFileDescriptor tunFd = mTunFd.get(); if (tunFd == null) { return; } String socksServerAddress = "127.0.0.1:" + Integer.toString(mLocalSocksProxyPort.get()); String udpgwServerAddress = "127.0.0.1:" + Integer.toString(UDPGW_SERVER_PORT); // We may call routeThroughTunnel and stopRouteThroughTunnel more than once within the same // VPN session. Since stopTun2Socks() closes the FD passed to startTun2Socks() we will use a // dup of the original tun FD and close the original only when we call stopVpn(). // // Note that ParcelFileDescriptor.dup() may throw an IOException. try { startTun2Socks( tunFd.dup(), VPN_INTERFACE_MTU, mPrivateAddress.mRouter, VPN_INTERFACE_NETMASK, socksServerAddress, udpgwServerAddress, true); mHostService.onDiagnosticMessage("routing through tunnel"); // TODO: should double-check tunnel routing; see: // https://bitbucket.org/psiphon/psiphon-circumvention-system/src/1dc5e4257dca99790109f3bf374e8ab3a0ead4d7/Android/PsiphonAndroidLibrary/src/com/psiphon3/psiphonlibrary/TunnelCore.java?at=default#cl-779 } catch (IOException e) { mHostService.onDiagnosticMessage("routing through tunnel error: " + e); } } public void stopRouteThroughTunnel() { if (mRoutingThroughTunnel.compareAndSet(true, false)) { stopTun2Socks(); } } // Throws an exception in error conditions. In the case of an exception, the routing // started by startRouting() is not immediately torn down (this allows the caller to control // exactly when VPN routing is stopped); caller should call stop() to clean up. public synchronized void startTunneling(String embeddedServerEntries) throws Exception { startPsiphon(embeddedServerEntries); } // Note: to avoid deadlock, do not call directly from a HostService callback; // instead post to a Handler if necessary to trigger from a HostService callback. // For example, deadlock can occur when a Notice callback invokes stop() since stop() calls // Psi.stop() which will block waiting for tunnel-core Controller to shutdown which in turn // waits for Notice callback invoker to stop, meanwhile the callback thread has blocked waiting // for stop(). public synchronized void stop() { stopVpn(); stopPsiphon(); mVpnMode.set(false); mLocalSocksProxyPort.set(0); } // Note: same deadlock note as stop(). public synchronized void restartPsiphon() throws Exception { stopPsiphon(); startPsiphon(""); } public synchronized void reconnectPsiphon() throws Exception { Psi.reconnectTunnel(); } public void setClientPlatformAffixes(String prefix, String suffix) { mClientPlatformPrefix.set(prefix); mClientPlatformSuffix.set(suffix); } public String exportExchangePayload() { return Psi.exportExchangePayload(); } public boolean importExchangePayload(String payload) { return Psi.importExchangePayload(payload); } // Writes Go runtime profile information to a set of files in the specifiec output directory. // cpuSampleDurationSeconds and blockSampleDurationSeconds determines how to long to wait and // sample profiles that require active sampling. When set to 0, these profiles are skipped. public void writeRuntimeProfiles(String outputDirectory, int cpuSampleDurationSeconnds, int blockSampleDurationSeconds) { Psi.writeRuntimeProfiles(outputDirectory, cpuSampleDurationSeconnds, blockSampleDurationSeconds); } // The interface for managing the Psiphon feedback upload operations. // Warnings: // - Should not be used in the same process as PsiphonTunnel. // - Only a single instance of PsiphonTunnelFeedback should be used at a time. Using multiple // instances in parallel, or concurrently, will result in undefined behavior. public static class PsiphonTunnelFeedback { final private ExecutorService workQueue; final private ExecutorService callbackQueue; public PsiphonTunnelFeedback() { workQueue = Executors.newSingleThreadExecutor(); callbackQueue = Executors.newSingleThreadExecutor(); } @Override protected void finalize() throws Throwable { // Ensure the queues are cleaned up. shutdownAndAwaitTermination(callbackQueue); shutdownAndAwaitTermination(workQueue); super.finalize(); } void shutdownAndAwaitTermination(ExecutorService pool) { try { // Wait a while for existing tasks to terminate if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { pool.shutdownNow(); // Cancel currently executing tasks // Wait a while for tasks to respond to being cancelled if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { System.err.println("PsiphonTunnelFeedback: pool did not terminate"); return; } } } catch (InterruptedException ie) { // (Re-)Cancel if current thread also interrupted pool.shutdownNow(); // Preserve interrupt status Thread.currentThread().interrupt(); } } // Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics // information in a particular format, then calls this function to upload it for later // investigation. The feedback compatible config and upload path must be provided by // Psiphon Inc. This call is asynchronous and returns before the upload completes. The // operation has completed when sendFeedbackCompleted() is called on the provided // HostFeedbackHandler. The provided HostLogger will be called to log informational notices, // including warnings. // // Warnings: // - Only one active upload is supported at a time. An ongoing upload will be cancelled if // this function is called again before it completes. // - An ongoing feedback upload started with startSendFeedback() should be stopped with // stopSendFeedback() before the process exits. This ensures that any underlying resources // are cleaned up; failing to do so may result in data store corruption or other undefined // behavior. // - PsiphonTunnel.startTunneling and startSendFeedback both make an attempt to migrate // persistent files from legacy locations in a one-time operation. If these functions are // called in parallel, then there is a chance that the migration attempts could execute at // the same time and result in non-fatal errors in one, or both, of the migration // operations. public void startSendFeedback(Context context, HostFeedbackHandler feedbackHandler, HostLogger logger, String feedbackConfigJson, String diagnosticsJson, String uploadPath, String clientPlatformPrefix, String clientPlatformSuffix) { workQueue.submit(new Runnable() { @Override public void run() { try { // Adds fields used in feedback upload, e.g. client platform. String psiphonConfig = buildPsiphonConfig(context, logger, feedbackConfigJson, clientPlatformPrefix, clientPlatformSuffix, false, 0); Psi.startSendFeedback(psiphonConfig, diagnosticsJson, uploadPath, new PsiphonProviderFeedbackHandler() { @Override public void sendFeedbackCompleted(java.lang.Exception e) { callbackQueue.submit(new Runnable() { @Override public void run() { feedbackHandler.sendFeedbackCompleted(e); } }); } }, new PsiphonProviderNetwork() { @Override public long hasNetworkConnectivity() { boolean hasConnectivity = PsiphonTunnel.hasNetworkConnectivity(context); // TODO: change to bool return value once gobind supports that type return hasConnectivity ? 1 : 0; } @Override public String getNetworkID() { return PsiphonTunnel.getNetworkID(context, mPsiphonTunnel.isVpnMode()); } @Override public String iPv6Synthesize(String IPv4Addr) { // Unused on Android. return PsiphonTunnel.iPv6Synthesize(IPv4Addr); } @Override public long hasIPv6Route() { return PsiphonTunnel.hasIPv6Route(context, logger); } }, new PsiphonProviderNoticeHandler() { @Override public void notice(String noticeJSON) { try { JSONObject notice = new JSONObject(noticeJSON); String noticeType = notice.getString("noticeType"); if (noticeType == null) { return; } JSONObject data = notice.getJSONObject("data"); if (data == null) { return; } String diagnosticMessage = noticeType + ": " + data.toString(); callbackQueue.submit(new Runnable() { @Override public void run() { logger.onDiagnosticMessage(diagnosticMessage); } }); } catch (java.lang.Exception e) { callbackQueue.submit(new Runnable() { @Override public void run() { logger.onDiagnosticMessage("Error handling notice " + e.toString()); } }); } } }, false, // Do not use IPv6 synthesizer for Android true // Use hasIPv6Route on Android ); } catch (java.lang.Exception e) { callbackQueue.submit(new Runnable() { @Override public void run() { feedbackHandler.sendFeedbackCompleted(new Exception("Error sending feedback", e)); } }); } } }); } // Interrupt an in-progress feedback upload operation started with startSendFeedback(). This // call is asynchronous and returns a future which is fulfilled when the underlying stop // operation completes. public Future stopSendFeedback() { return workQueue.submit(new Runnable() { @Override public void run() { Psi.stopSendFeedback(); } }, null); } } //---------------------------------------------------------------------------------------------- // VPN Routing //---------------------------------------------------------------------------------------------- private final static String VPN_INTERFACE_NETMASK = "255.255.255.0"; private final static int VPN_INTERFACE_MTU = 1500; private final static int UDPGW_SERVER_PORT = 7300; // Note: Atomic variables used for getting/setting local proxy port, routing flag, and // tun fd, as these functions may be called via PsiphonProvider callbacks. Do not use // synchronized functions as stop() is synchronized and a deadlock is possible as callbacks // can be called while stop holds the lock. @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private boolean startVpn() throws Exception { mVpnMode.set(true); mPrivateAddress = selectPrivateAddress(); Locale previousLocale = Locale.getDefault(); final String errorMessage = "startVpn failed"; try { // Workaround for https://code.google.com/p/android/issues/detail?id=61096 Locale.setDefault(new Locale("en")); int mtu = VPN_INTERFACE_MTU; String dnsResolver = mPrivateAddress.mRouter; ParcelFileDescriptor tunFd = ((VpnService.Builder) mHostService.newVpnServiceBuilder()) .setSession(mHostService.getAppName()) .setMtu(mtu) .addAddress(mPrivateAddress.mIpAddress, mPrivateAddress.mPrefixLength) .addRoute("0.0.0.0", 0) .addRoute(mPrivateAddress.mSubnet, mPrivateAddress.mPrefixLength) .addDnsServer(dnsResolver) .establish(); if (tunFd == null) { // As per http://developer.android.com/reference/android/net/VpnService.Builder.html#establish%28%29, // this application is no longer prepared or was revoked. return false; } mTunFd.set(tunFd); mRoutingThroughTunnel.set(false); mHostService.onDiagnosticMessage("VPN established"); } catch(IllegalArgumentException e) { throw new Exception(errorMessage, e); } catch(IllegalStateException e) { throw new Exception(errorMessage, e); } catch(SecurityException e) { throw new Exception(errorMessage, e); } finally { // Restore the original locale. Locale.setDefault(previousLocale); } return true; } @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private ParcelFileDescriptor startDummyVpn(VpnService.Builder vpnServiceBuilder) throws Exception { PrivateAddress privateAddress = selectPrivateAddress(); Locale previousLocale = Locale.getDefault(); final String errorMessage = "startDummyVpn failed"; final ParcelFileDescriptor tunFd; try { // Workaround for https://code.google.com/p/android/issues/detail?id=61096 Locale.setDefault(new Locale("en")); int mtu = VPN_INTERFACE_MTU; String dnsResolver = privateAddress.mRouter; tunFd = vpnServiceBuilder .setSession(mHostService.getAppName()) .setMtu(mtu) .addAddress(privateAddress.mIpAddress, privateAddress.mPrefixLength) .addRoute("0.0.0.0", 0) .addRoute(privateAddress.mSubnet, privateAddress.mPrefixLength) .addDnsServer(dnsResolver) .establish(); } catch(IllegalArgumentException e) { throw new Exception(errorMessage, e); } catch(IllegalStateException e) { throw new Exception(errorMessage, e); } catch(SecurityException e) { throw new Exception(errorMessage, e); } finally { // Restore the original locale. Locale.setDefault(previousLocale); } return tunFd; } private boolean isVpnMode() { return mVpnMode.get(); } private void setLocalSocksProxyPort(int port) { mLocalSocksProxyPort.set(port); } private void stopVpn() { stopTun2Socks(); ParcelFileDescriptor tunFd = mTunFd.getAndSet(null); if (tunFd != null) { try { tunFd.close(); } catch (IOException e) { } } mRoutingThroughTunnel.set(false); } //---------------------------------------------------------------------------------------------- // PsiphonProvider (Core support) interface implementation //---------------------------------------------------------------------------------------------- // The PsiphonProvider functions are called from Go, and must be public to be accessible // via the gobind mechanim. To avoid making internal implementation functions public, // PsiphonProviderShim is used as a wrapper. private class PsiphonProviderShim implements PsiphonProvider { private PsiphonTunnel mPsiphonTunnel; public PsiphonProviderShim(PsiphonTunnel psiphonTunnel) { mPsiphonTunnel = psiphonTunnel; } @Override public void notice(String noticeJSON) { mPsiphonTunnel.notice(noticeJSON); } @Override public String bindToDevice(long fileDescriptor) throws Exception { return mPsiphonTunnel.bindToDevice(fileDescriptor); } @Override public long hasNetworkConnectivity() { return mPsiphonTunnel.hasNetworkConnectivity(); } @Override public String getDNSServersAsString() { return mPsiphonTunnel.getDNSServers(mHostService.getContext(), mHostService); } @Override public String iPv6Synthesize(String IPv4Addr) { return PsiphonTunnel.iPv6Synthesize(IPv4Addr); } @Override public long hasIPv6Route() { return PsiphonTunnel.hasIPv6Route(mHostService.getContext(), mHostService); } @Override public String getNetworkID() { return PsiphonTunnel.getNetworkID(mHostService.getContext(), mPsiphonTunnel.isVpnMode()); } } private void notice(String noticeJSON) { handlePsiphonNotice(noticeJSON); } @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private String bindToDevice(long fileDescriptor) throws Exception { if (!((VpnService)mHostService.getVpnService()).protect((int)fileDescriptor)) { throw new Exception("protect socket failed"); } return ""; } private long hasNetworkConnectivity() { boolean hasConnectivity = hasNetworkConnectivity(mHostService.getContext()); boolean wasWaitingForNetworkConnectivity = mIsWaitingForNetworkConnectivity.getAndSet(!hasConnectivity); // HasNetworkConnectivity may be called many times, but only invoke // callbacks once per loss or resumption of connectivity, so, e.g., // the HostService may log a single message. if (!hasConnectivity && !wasWaitingForNetworkConnectivity) { mHostService.onStartedWaitingForNetworkConnectivity(); } else if (hasConnectivity && wasWaitingForNetworkConnectivity) { mHostService.onStoppedWaitingForNetworkConnectivity(); } // TODO: change to bool return value once gobind supports that type return hasConnectivity ? 1 : 0; } private String getDNSServers(Context context, HostLogger logger) { // Use the DNS servers set by mNetworkMonitor, // mActiveNetworkDNSServers, when available. It's the most reliable // mechanism. Otherwise fallback to getActiveNetworkDNSServers. // // mActiveNetworkDNSServers is not available on API < 21 // (LOLLIPOP). mActiveNetworkDNSServers may also be temporarily // unavailable if the last active network has been lost and no new // one has yet replaced it. String servers = mActiveNetworkDNSServers.get(); if (servers != "") { return servers; } try { // Use the workaround, comma-delimited format required for gobind. servers = TextUtils.join(",", getActiveNetworkDNSServers(context, mVpnMode.get())); } catch (Exception e) { logger.onDiagnosticMessage("failed to get active network DNS resolver: " + e.getMessage()); // Alternate DNS servers will be provided by psiphon-tunnel-core // config or tactics. } return servers; } private static String iPv6Synthesize(String IPv4Addr) { // Unused on Android. return IPv4Addr; } private static long hasIPv6Route(Context context, HostLogger logger) { boolean hasRoute = false; try { hasRoute = hasIPv6Route(context); } catch (Exception e) { logger.onDiagnosticMessage("failed to check IPv6 route: " + e.getMessage()); } // TODO: change to bool return value once gobind supports that type return hasRoute ? 1 : 0; } private static String getNetworkID(Context context, boolean isVpnMode) { // TODO: getActiveNetworkInfo is deprecated in API 29; once // getActiveNetworkInfo is no longer available, use // mActiveNetworkType which is updated by mNetworkMonitor. // The network ID contains potential PII. In tunnel-core, the network ID // is used only locally in the client and not sent to the server. // // See network ID requirements here: // https://godoc.org/github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon#NetworkIDGetter String networkID = "UNKNOWN"; ConnectivityManager connectivityManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!isVpnMode) { NetworkCapabilities capabilities = null; try { Network nw = connectivityManager.getActiveNetwork(); capabilities = connectivityManager.getNetworkCapabilities(nw); } catch (java.lang.Exception e) { // May get exceptions due to missing permissions like android.permission.ACCESS_NETWORK_STATE. // Apps using the Psiphon Library and lacking android.permission.ACCESS_NETWORK_STATE will // proceed and use tactics, but with "UNKNOWN" as the sole network ID. } if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { return "VPN"; } } } NetworkInfo activeNetworkInfo = null; try { activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); } catch (java.lang.Exception e) { // May get exceptions due to missing permissions like android.permission.ACCESS_NETWORK_STATE. // Apps using the Psiphon Library and lacking android.permission.ACCESS_NETWORK_STATE will // proceed and use tactics, but with "UNKNOWN" as the sole network ID. } if (activeNetworkInfo != null && activeNetworkInfo.getType() == ConnectivityManager.TYPE_WIFI) { networkID = "WIFI"; try { WifiManager wifiManager = (WifiManager)context.getSystemService(Context.WIFI_SERVICE); WifiInfo wifiInfo = wifiManager.getConnectionInfo(); if (wifiInfo != null) { String wifiNetworkID = wifiInfo.getBSSID(); if (wifiNetworkID.equals("02:00:00:00:00:00")) { // "02:00:00:00:00:00" is reported when the app does not have the ACCESS_COARSE_LOCATION permission: // https://developer.android.com/about/versions/marshmallow/android-6.0-changes#behavior-hardware-id // The Psiphon client should allow the user to opt-in to this permission. If they decline, fail over // to using the WiFi IP address. wifiNetworkID = String.valueOf(wifiInfo.getIpAddress()); } networkID += "-" + wifiNetworkID; } } catch (java.lang.Exception e) { // May get exceptions due to missing permissions like android.permission.ACCESS_WIFI_STATE. // Fall through and use just "WIFI" } } else if (activeNetworkInfo != null && activeNetworkInfo.getType() == ConnectivityManager.TYPE_MOBILE) { networkID = "MOBILE"; try { TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null) { networkID += "-" + telephonyManager.getNetworkOperator(); } } catch (java.lang.Exception e) { // May get exceptions due to missing permissions. // Fall through and use just "MOBILE" } } return networkID; } //---------------------------------------------------------------------------------------------- // Psiphon Tunnel Core //---------------------------------------------------------------------------------------------- private void startPsiphon(String embeddedServerEntries) throws Exception { stopPsiphon(); mIsWaitingForNetworkConnectivity.set(false); mHostService.onDiagnosticMessage("starting Psiphon library"); try { // mNetworkMonitor.start() will wait up to 1 second before returning to give the network // callback a chance to populate active network properties before we start the tunnel. mNetworkMonitor.start(mHostService.getContext()); Psi.start( loadPsiphonConfig(mHostService.getContext()), embeddedServerEntries, "", new PsiphonProviderShim(this), isVpnMode(), false, // Do not use IPv6 synthesizer for Android true // Use hasIPv6Route on Android ); } catch (java.lang.Exception e) { throw new Exception("failed to start Psiphon library", e); } mHostService.onDiagnosticMessage("Psiphon library started"); } private void stopPsiphon() { mHostService.onDiagnosticMessage("stopping Psiphon library"); mNetworkMonitor.stop(mHostService.getContext()); Psi.stop(); mHostService.onDiagnosticMessage("Psiphon library stopped"); } private String loadPsiphonConfig(Context context) throws IOException, JSONException, Exception { return buildPsiphonConfig(context, mHostService, mHostService.getPsiphonConfig(), mClientPlatformPrefix.get(), mClientPlatformSuffix.get(), isVpnMode(), mLocalSocksProxyPort.get()); } private static String buildPsiphonConfig(Context context, HostLogger logger, String psiphonConfig, String clientPlatformPrefix, String clientPlatformSuffix, boolean isVpnMode, Integer localSocksProxyPort) throws IOException, JSONException, Exception { // Load settings from the raw resource JSON config file and // update as necessary. Then write JSON to disk for the Go client. JSONObject json = new JSONObject(psiphonConfig); // On Android, this directory must be set to the app private storage area. // The Psiphon library won't be able to use its current working directory // and the standard temporary directories do not exist. if (!json.has("DataRootDirectory")) { File dataRootDirectory = defaultDataRootDirectory(context); if (!dataRootDirectory.exists()) { boolean created = dataRootDirectory.mkdir(); if (!created) { throw new Exception("failed to create data root directory: " + dataRootDirectory.getPath()); } } json.put("DataRootDirectory", defaultDataRootDirectory(context)); } // Migrate datastore files from legacy directory. if (!json.has("DataStoreDirectory")) { json.put("MigrateDataStoreDirectory", context.getFilesDir()); } // Migrate remote server list downloads from legacy location. if (!json.has("RemoteServerListDownloadFilename")) { File remoteServerListDownload = new File(context.getFilesDir(), "remote_server_list"); json.put("MigrateRemoteServerListDownloadFilename", remoteServerListDownload.getAbsolutePath()); } // Migrate obfuscated server list download files from legacy directory. File oslDownloadDir = new File(context.getFilesDir(), "osl"); json.put("MigrateObfuscatedServerListDownloadDirectory", oslDownloadDir.getAbsolutePath()); // Continue to run indefinitely until connected if (!json.has("EstablishTunnelTimeoutSeconds")) { json.put("EstablishTunnelTimeoutSeconds", 0); } json.put("EmitBytesTransferred", true); if (localSocksProxyPort != 0 && (!json.has("LocalSocksProxyPort") || json.getInt("LocalSocksProxyPort") == 0)) { // When mLocalSocksProxyPort is set, tun2socks is already configured // to use that port value. So we force use of the same port. // A side-effect of this is that changing the SOCKS port preference // has no effect with restartPsiphon(), a full stop() is necessary. json.put("LocalSocksProxyPort", localSocksProxyPort); } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { try { json.put( "TrustedCACertificatesFilename", setupTrustedCertificates(context, logger)); } catch (Exception e) { logger.onDiagnosticMessage(e.getMessage()); } } json.put("DeviceRegion", getDeviceRegion(context)); StringBuilder clientPlatform = new StringBuilder(); if (clientPlatformPrefix.length() > 0) { clientPlatform.append(clientPlatformPrefix); } clientPlatform.append("Android_"); clientPlatform.append(Build.VERSION.RELEASE); clientPlatform.append("_"); clientPlatform.append(context.getPackageName()); if (clientPlatformSuffix.length() > 0) { clientPlatform.append(clientPlatformSuffix); } json.put("ClientPlatform", clientPlatform.toString().replaceAll("[^\\w\\-\\.]", "_")); return json.toString(); } private void handlePsiphonNotice(String noticeJSON) { try { // All notices are sent on as diagnostic messages // except those that may contain private user data. boolean diagnostic = true; JSONObject notice = new JSONObject(noticeJSON); String noticeType = notice.getString("noticeType"); if (noticeType.equals("Tunnels")) { int count = notice.getJSONObject("data").getInt("count"); if (count == 0) { mHostService.onConnecting(); } else if (count == 1) { if (isVpnMode() && mShouldRouteThroughTunnelAutomatically) { routeThroughTunnel(); } mHostService.onConnected(); } // count > 1 is an additional multi-tunnel establishment, and not reported. } else if (noticeType.equals("AvailableEgressRegions")) { JSONArray egressRegions = notice.getJSONObject("data").getJSONArray("regions"); ArrayList regions = new ArrayList(); for (int i=0; i regions = new ArrayList(); for (int i=0; i authorizations = new ArrayList(); for (int i=0; i actionURLsList = new ArrayList(); for (int i=0; i= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { keyStore = KeyStore.getInstance("AndroidCAStore"); keyStore.load(null, null); } else { keyStore = KeyStore.getInstance("BKS"); FileInputStream inputStream = new FileInputStream("/etc/security/cacerts.bks"); try { keyStore.load(inputStream, "changeit".toCharArray()); } finally { if (inputStream != null) { inputStream.close(); } } } Enumeration aliases = keyStore.aliases(); while (aliases.hasMoreElements()) { String alias = aliases.nextElement(); X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias); output.println("-----BEGIN CERTIFICATE-----"); String pemCert = new String(Base64.encode(cert.getEncoded(), Base64.NO_WRAP), "UTF-8"); // OpenSSL appears to reject the default linebreaking done by Base64.encode, // so we manually linebreak every 64 characters for (int i = 0; i < pemCert.length() ; i+= 64) { output.println(pemCert.substring(i, Math.min(i + 64, pemCert.length()))); } output.println("-----END CERTIFICATE-----"); } logger.onDiagnosticMessage("prepared PsiphonCAStore"); return file.getAbsolutePath(); } finally { if (output != null) { output.close(); } } } catch (KeyStoreException e) { throw new Exception(errorMessage, e); } catch (NoSuchAlgorithmException e) { throw new Exception(errorMessage, e); } catch (CertificateException e) { throw new Exception(errorMessage, e); } catch (IOException e) { throw new Exception(errorMessage, e); } } private static String getDeviceRegion(Context context) { String region = ""; TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null) { region = telephonyManager.getSimCountryIso(); if (region == null) { region = ""; } if (region.length() == 0 && telephonyManager.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) { region = telephonyManager.getNetworkCountryIso(); if (region == null) { region = ""; } } } if (region.length() == 0) { Locale defaultLocale = Locale.getDefault(); if (defaultLocale != null) { region = defaultLocale.getCountry(); } } return region.toUpperCase(Locale.US); } //---------------------------------------------------------------------------------------------- // Tun2Socks //---------------------------------------------------------------------------------------------- @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) private void startTun2Socks( final ParcelFileDescriptor vpnInterfaceFileDescriptor, final int vpnInterfaceMTU, final String vpnIpAddress, final String vpnNetMask, final String socksServerAddress, final String udpgwServerAddress, final boolean udpgwTransparentDNS) { if (mTun2SocksThread != null) { return; } mTun2SocksThread = new Thread(new Runnable() { @Override public void run() { runTun2Socks( vpnInterfaceFileDescriptor.detachFd(), vpnInterfaceMTU, vpnIpAddress, vpnNetMask, socksServerAddress, udpgwServerAddress, udpgwTransparentDNS ? 1 : 0); } }); mTun2SocksThread.start(); mHostService.onDiagnosticMessage("tun2socks started"); } private void stopTun2Socks() { if (mTun2SocksThread != null) { try { terminateTun2Socks(); mTun2SocksThread.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } mTun2SocksThread = null; mHostService.onDiagnosticMessage("tun2socks stopped"); } } public static void logTun2Socks(String level, String channel, String msg) { String logMsg = "tun2socks: " + level + "(" + channel + "): " + msg; mPsiphonTunnel.mHostService.onDiagnosticMessage(logMsg); } private native static int runTun2Socks( int vpnInterfaceFileDescriptor, int vpnInterfaceMTU, String vpnIpAddress, String vpnNetMask, String socksServerAddress, String udpgwServerAddress, int udpgwTransparentDNS); private native static int terminateTun2Socks(); private native static int enableUdpGwKeepalive(); private native static int disableUdpGwKeepalive(); //---------------------------------------------------------------------------------------------- // Implementation: Network Utils //---------------------------------------------------------------------------------------------- private static boolean hasNetworkConnectivity(Context context) { ConnectivityManager connectivityManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { return false; } NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); return networkInfo != null && networkInfo.isConnected(); } private static class PrivateAddress { final public String mIpAddress; final public String mSubnet; final public int mPrefixLength; final public String mRouter; public PrivateAddress(String ipAddress, String subnet, int prefixLength, String router) { mIpAddress = ipAddress; mSubnet = subnet; mPrefixLength = prefixLength; mRouter = router; } } private static PrivateAddress selectPrivateAddress() throws Exception { // Select one of 10.0.0.1, 172.16.0.1, or 192.168.0.1 depending on // which private address range isn't in use. Map candidates = new HashMap(); candidates.put( "10", new PrivateAddress("10.0.0.1", "10.0.0.0", 8, "10.0.0.2")); candidates.put("172", new PrivateAddress("172.16.0.1", "172.16.0.0", 12, "172.16.0.2")); candidates.put("192", new PrivateAddress("192.168.0.1", "192.168.0.0", 16, "192.168.0.2")); candidates.put("169", new PrivateAddress("169.254.1.1", "169.254.1.0", 24, "169.254.1.2")); Enumeration netInterfaces; try { netInterfaces = NetworkInterface.getNetworkInterfaces(); } catch (SocketException e) { throw new Exception("selectPrivateAddress failed", e); } if (netInterfaces == null) { throw new Exception("no network interfaces found"); } for (NetworkInterface netInterface : Collections.list(netInterfaces)) { for (InetAddress inetAddress : Collections.list(netInterface.getInetAddresses())) { if (inetAddress instanceof Inet4Address) { String ipAddress = inetAddress.getHostAddress(); if (ipAddress.startsWith("10.")) { candidates.remove("10"); } else if ( ipAddress.length() >= 6 && ipAddress.substring(0, 6).compareTo("172.16") >= 0 && ipAddress.substring(0, 6).compareTo("172.31") <= 0) { candidates.remove("172"); } else if (ipAddress.startsWith("192.168")) { candidates.remove("192"); } } } } if (candidates.size() > 0) { return candidates.values().iterator().next(); } throw new Exception("no private address available"); } private static Collection getActiveNetworkDNSServers(Context context, boolean isVpnMode) throws Exception { ArrayList servers = new ArrayList(); for (InetAddress serverAddress : getActiveNetworkDNSServerAddresses(context, isVpnMode)) { String server = serverAddress.toString(); // strip the leading slash e.g., "/192.168.1.1" if (server.startsWith("/")) { server = server.substring(1); } servers.add(server); } if (servers.isEmpty()) { throw new Exception("no active network DNS resolver"); } return servers; } private static Collection getActiveNetworkDNSServerAddresses(Context context, boolean isVpnMode) throws Exception { final String errorMessage = "getActiveNetworkDNSServerAddresses failed"; ArrayList dnsAddresses = new ArrayList(); ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { throw new Exception(errorMessage, new Throwable("couldn't get ConnectivityManager system service")); } try { // Hidden API: // // - Only available in Android 4.0+ // - No guarantee will be available beyond 4.2, or on all vendor // devices // - Field reports indicate this is no longer working on some -- // but not all -- Android 10+ devices Class LinkPropertiesClass = Class.forName("android.net.LinkProperties"); Method getActiveLinkPropertiesMethod = ConnectivityManager.class.getMethod("getActiveLinkProperties", new Class []{}); Object linkProperties = getActiveLinkPropertiesMethod.invoke(connectivityManager); if (linkProperties != null) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { Method getDnsesMethod = LinkPropertiesClass.getMethod("getDnses", new Class []{}); Collection dnses = (Collection)getDnsesMethod.invoke(linkProperties); for (Object dns : dnses) { dnsAddresses.add((InetAddress)dns); } } else { // LinkProperties is public in API 21 (and the DNS function signature has changed) for (InetAddress dns : ((LinkProperties)linkProperties).getDnsServers()) { dnsAddresses.add(dns); } } } } catch (ClassNotFoundException e) { } catch (NoSuchMethodException e) { } catch (IllegalArgumentException e) { } catch (IllegalAccessException e) { } catch (InvocationTargetException e) { } catch (NullPointerException e) { } if (!dnsAddresses.isEmpty()) { return dnsAddresses; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // This case is attempted only when the hidden API fails: // // - Testing shows the hidden API still works more reliably on // some Android 11+ devices // - Testing indicates that the NetworkRequest can sometimes // select the wrong network // - e.g., mobile instead of WiFi, and return the wrong DNS // servers // - there's currently no way to filter for the "currently // active default data network" returned by, e.g., the // deprecated getActiveNetworkInfo // - we cannot add the NET_CAPABILITY_FOREGROUND capability to // the NetworkRequest at this time due to target SDK // constraints NetworkRequest.Builder networkRequestBuilder = new NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); if (isVpnMode) { // In VPN mode, we want the DNS servers for the underlying physical network. networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); } NetworkRequest networkRequest = networkRequestBuilder.build(); final CountDownLatch countDownLatch = new CountDownLatch(1); try { ConnectivityManager.NetworkCallback networkCallback = new ConnectivityManager.NetworkCallback() { @Override public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) { dnsAddresses.addAll(linkProperties.getDnsServers()); countDownLatch.countDown(); } }; connectivityManager.registerNetworkCallback(networkRequest, networkCallback); countDownLatch.await(1, TimeUnit.SECONDS); connectivityManager.unregisterNetworkCallback(networkCallback); } catch (RuntimeException ignored) { // Failed to register network callback } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } return dnsAddresses; } private static boolean hasIPv6Route(Context context) throws Exception { try { // This logic mirrors the logic in // psiphon/common/resolver.hasRoutableIPv6Interface. That // function currently doesn't work on Android due to Go's // net.InterfaceAddrs failing on Android SDK 30+ (see Go issue // 40569). hasIPv6Route provides the same functionality via a // callback into Java code. for (NetworkInterface netInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) { if (netInterface.isUp() && !netInterface.isLoopback() && !netInterface.isPointToPoint()) { for (InetAddress address : Collections.list(netInterface.getInetAddresses())) { // Per https://developer.android.com/reference/java/net/Inet6Address#textual-representation-of-ip-addresses, // "Java will never return an IPv4-mapped address. // These classes can take an IPv4-mapped address as // input, both in byte array and text // representation. However, it will be converted // into an IPv4 address." As such, when the type of // the IP address is Inet6Address, this should be // an actual IPv6 address. if (address instanceof Inet6Address && !address.isLinkLocalAddress() && !address.isSiteLocalAddress() && !address.isMulticastAddress ()) { return true; } } } } } catch (SocketException e) { throw new Exception("hasIPv6Route failed", e); } return false; } //---------------------------------------------------------------------------------------------- // Exception //---------------------------------------------------------------------------------------------- public static class Exception extends java.lang.Exception { private static final long serialVersionUID = 1L; public Exception(String message) { super(message); } public Exception(String message, Throwable cause) { super(message + ": " + cause.getMessage()); } } //---------------------------------------------------------------------------------------------- // Network connectivity monitor //---------------------------------------------------------------------------------------------- private static class NetworkMonitor { private final NetworkChangeListener listener; private ConnectivityManager.NetworkCallback networkCallback; public NetworkMonitor( NetworkChangeListener listener) { this.listener = listener; } private void start(Context context) throws InterruptedException { final CountDownLatch setNetworkPropertiesCountDownLatch = new CountDownLatch(1); // Need API 21(LOLLIPOP)+ for ConnectivityManager.NetworkCallback if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { return; } ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { return; } networkCallback = new ConnectivityManager.NetworkCallback() { private boolean isInitialState = true; private Network currentActiveNetwork; private void consumeActiveNetwork(Network network) { if (isInitialState) { isInitialState = false; setCurrentActiveNetworkAndProperties(network); return; } if (!network.equals(currentActiveNetwork)) { setCurrentActiveNetworkAndProperties(network); if (listener != null) { listener.onChanged(); } } } private void consumeLostNetwork(Network network) { if (network.equals(currentActiveNetwork)) { setCurrentActiveNetworkAndProperties(null); if (listener != null) { listener.onChanged(); } } } private void setCurrentActiveNetworkAndProperties(Network network) { currentActiveNetwork = network; if (network == null) { mPsiphonTunnel.mActiveNetworkType.set("NONE"); mPsiphonTunnel.mActiveNetworkDNSServers.set(""); mPsiphonTunnel.mHostService.onDiagnosticMessage("NetworkMonitor: clear current active network"); } else { String networkType = "UNKNOWN"; try { // Limitation: a network may have both CELLULAR // and WIFI transports, or different network // transport types entirely. This logic currently // mimics the type determination logic in // getNetworkID. NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network); if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { networkType = "VPN"; } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { networkType = "MOBILE"; } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { networkType = "WIFI"; } } catch (java.lang.Exception e) { } mPsiphonTunnel.mActiveNetworkType.set(networkType); ArrayList servers = new ArrayList(); try { LinkProperties linkProperties = connectivityManager.getLinkProperties(network); List serverAddresses = linkProperties.getDnsServers(); for (InetAddress serverAddress : serverAddresses) { String server = serverAddress.toString(); if (server.startsWith("/")) { server = server.substring(1); } servers.add(server); } } catch (java.lang.Exception e) { } // Use the workaround, comma-delimited format required for gobind. mPsiphonTunnel.mActiveNetworkDNSServers.set(TextUtils.join(",", servers)); String message = "NetworkMonitor: set current active network " + networkType; if (!servers.isEmpty()) { // The DNS server address is potential PII and not logged. message += " with DNS"; } mPsiphonTunnel.mHostService.onDiagnosticMessage(message); } setNetworkPropertiesCountDownLatch.countDown(); } @Override public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) { super.onCapabilitiesChanged(network, capabilities); // Need API 23(M)+ for NET_CAPABILITY_VALIDATED if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return; } // https://developer.android.com/reference/android/net/NetworkCapabilities#NET_CAPABILITY_VALIDATED // Indicates that connectivity on this network was successfully validated. // For example, for a network with NET_CAPABILITY_INTERNET, it means that Internet connectivity was // successfully detected. if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) { consumeActiveNetwork(network); } } @Override public void onAvailable(Network network) { super.onAvailable(network); // Skip on API 26(O)+ because onAvailable is guaranteed to be followed by // onCapabilitiesChanged if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return; } consumeActiveNetwork(network); } @Override public void onLost(Network network) { super.onLost(network); consumeLostNetwork(network); } }; try { // When searching for a network to satisfy a request, all capabilities requested must be satisfied. NetworkRequest.Builder builder = new NetworkRequest.Builder() // Indicates that this network should be able to reach the internet. .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); if (mPsiphonTunnel.mVpnMode.get()) { // If we are in the VPN mode then ensure we monitor only the VPN's underlying // active networks and not self. builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); } else { // If we are NOT in the VPN mode then monitor default active networks with the // Internet capability, including VPN, to ensure we won't trigger a reconnect in // case the VPN is up while the system switches the underlying network. builder.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); } NetworkRequest networkRequest = builder.build(); // We are using requestNetwork and not registerNetworkCallback here because we found // that the callbacks from requestNetwork are more accurate in terms of tracking // currently active network. Another alternative to use for tracking active network // would be registerDefaultNetworkCallback but a) it needs API >= 24 and b) doesn't // provide a way to set up monitoring of underlying networks only when VPN transport // is also active. connectivityManager.requestNetwork(networkRequest, networkCallback); } catch (RuntimeException ignored) { // Could be a security exception or any other runtime exception on customized firmwares. networkCallback = null; } // We are going to wait up to one second for the network callback to populate // active network properties before returning. setNetworkPropertiesCountDownLatch.await(1, TimeUnit.SECONDS); } private void stop(Context context) { if (networkCallback == null) { return; } // Need API 21(LOLLIPOP)+ for ConnectivityManager.NetworkCallback if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { return; } ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { return; } // Note: ConnectivityManager.unregisterNetworkCallback() may throw // "java.lang.IllegalArgumentException: NetworkCallback was not registered". // This scenario should be handled in the start() above but we'll add a try/catch // anyway to match the start's call to ConnectivityManager.registerNetworkCallback() try { connectivityManager.unregisterNetworkCallback(networkCallback); } catch (RuntimeException ignored) { } networkCallback = null; } public interface NetworkChangeListener { void onChanged(); } } }