Add toggle for auto-reconnection on VPN termination

This allows the user to run other VPN apps, disconnecting PCAPdroid, and
restart it when they terminate.

Closes #411
This commit is contained in:
emanuele-f 2024-02-18 22:34:26 +01:00
parent 85ded375e9
commit 630e911172
10 changed files with 353 additions and 28 deletions

View File

@ -128,6 +128,14 @@
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".VpnReconnectService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="waits for the termination of the active VpnService"/>
</service>
<receiver android:name="com.emanuelef.remote_capture.ActionReceiver" />

View File

@ -21,6 +21,7 @@ package com.emanuelef.remote_capture;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.VpnService;
import android.os.Handler;
@ -33,6 +34,7 @@ import androidx.activity.ComponentActivity;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
@ -41,33 +43,40 @@ import java.net.UnknownHostException;
public class CaptureHelper {
private static final String TAG = "CaptureHelper";
private final ComponentActivity mActivity;
private final ActivityResultLauncher<Intent> mLauncher;
private final Context mContext;
private final @Nullable ActivityResultLauncher<Intent> mLauncher;
private final boolean mResolveHosts;
private CaptureSettings mSettings;
private CaptureStartListener mListener;
public CaptureHelper(ComponentActivity activity, boolean resolve_hosts) {
mActivity = activity;
mContext = activity;
mResolveHosts = resolve_hosts;
mLauncher = activity.registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), this::captureServiceResult);
}
/** Note: This constructor does not handle the first-time VPN prepare */
public CaptureHelper(Context context) {
mContext = context;
mResolveHosts = true;
mLauncher = null;
}
private void captureServiceResult(final ActivityResult result) {
if(result.getResultCode() == Activity.RESULT_OK)
resolveHosts();
else if(mListener != null) {
Utils.showToastLong(mActivity, R.string.vpn_setup_failed);
Utils.showToastLong(mContext, R.string.vpn_setup_failed);
mListener.onCaptureStartResult(false);
}
}
private void startCaptureOk() {
final Intent intent = new Intent(mActivity, CaptureService.class);
final Intent intent = new Intent(mContext, CaptureService.class);
intent.putExtra("settings", mSettings);
ContextCompat.startForegroundService(mActivity, intent);
ContextCompat.startForegroundService(mContext, intent);
if(mListener != null)
mListener.onCaptureStartResult(true);
}
@ -121,7 +130,7 @@ public class CaptureHelper {
if(failed_host == null)
startCaptureOk();
else {
Utils.showToastLong(mActivity, R.string.host_resolution_failed, failed_host);
Utils.showToastLong(mContext, R.string.host_resolution_failed, failed_host);
mListener.onCaptureStartResult(false);
}
});
@ -139,23 +148,26 @@ public class CaptureHelper {
return;
}
Intent vpnPrepareIntent = VpnService.prepare(mActivity);
Intent vpnPrepareIntent = VpnService.prepare(mContext);
if(vpnPrepareIntent != null) {
new AlertDialog.Builder(mActivity)
.setMessage(R.string.vpn_setup_msg)
.setPositiveButton(R.string.ok, (dialog, whichButton) -> {
try {
mLauncher.launch(vpnPrepareIntent);
} catch (ActivityNotFoundException e) {
Utils.showToastLong(mActivity, R.string.no_intent_handler_found);
if (mLauncher != null)
new AlertDialog.Builder(mContext)
.setMessage(R.string.vpn_setup_msg)
.setPositiveButton(R.string.ok, (dialog, whichButton) -> {
try {
mLauncher.launch(vpnPrepareIntent);
} catch (ActivityNotFoundException e) {
Utils.showToastLong(mContext, R.string.no_intent_handler_found);
mListener.onCaptureStartResult(false);
}
})
.setOnCancelListener(dialog -> {
Utils.showToastLong(mContext, R.string.vpn_setup_failed);
mListener.onCaptureStartResult(false);
}
})
.setOnCancelListener(dialog -> {
Utils.showToastLong(mActivity, R.string.vpn_setup_failed);
mListener.onCaptureStartResult(false);
})
.show();
})
.show();
else if (mListener != null)
mListener.onCaptureStartResult(false);
} else
resolveHosts();
}

View File

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-21 - Emanuele Faranda
* Copyright 2020-24 - Emanuele Faranda
*/
package com.emanuelef.remote_capture;
@ -112,6 +112,7 @@ public class CaptureService extends VpnService implements Runnable {
final Condition mCaptureStopped = mLock.newCondition();
private ParcelFileDescriptor mParcelFileDescriptor;
private boolean mIsAlwaysOnVPN;
private boolean mRevoked;
private SharedPreferences mPrefs;
private CaptureSettings mSettings;
private Billing mBilling;
@ -236,6 +237,9 @@ public class CaptureService extends VpnService implements Runnable {
return abortStart();
}
if (VpnReconnectService.isAvailable())
VpnReconnectService.stopService();
mHandler = new Handler(Looper.getMainLooper());
mBilling = Billing.newInstance(this);
@ -605,6 +609,7 @@ public class CaptureService extends VpnService implements Runnable {
@Override
public void onRevoke() {
Log.d(CaptureService.TAG, "onRevoke");
mRevoked = true;
stopService();
super.onRevoke();
}
@ -1404,6 +1409,15 @@ public class CaptureService extends VpnService implements Runnable {
reloadDecryptionList();
reloadBlocklist();
reloadFirewallWhitelist();
} else if (cur_status == ServiceStatus.STOPPED) {
if (mRevoked && Prefs.restartOnDisconnect(mPrefs) && !mIsAlwaysOnVPN && (isVpnCapture() == 1)) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
Log.i(TAG, "VPN disconnected, starting reconnect service");
final Intent intent = new Intent(this, VpnReconnectService.class);
ContextCompat.startForegroundService(this, intent);
}
}
}
}

View File

@ -646,7 +646,7 @@ public class Utils {
// Using the deprecated API instead to keep things simple.
// https://developer.android.com/reference/android/net/ConnectivityManager#getAllNetworks()
@SuppressWarnings("deprecation")
public static boolean hasVPNRunning(Context context) {
public static Network getRunningVpn(Context context) {
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if(cm != null) {
try {
@ -657,7 +657,7 @@ public class Utils {
if ((cap != null) && cap.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
Log.d("hasVPNRunning", "detected VPN connection: " + net.toString());
return true;
return net;
}
}
} catch (SecurityException e) {
@ -666,7 +666,7 @@ public class Utils {
}
}
return false;
return null;
}
public static void showToast(Context context, int id, Object... args) {

View File

@ -0,0 +1,266 @@
/*
* This file is part of PCAPdroid.
*
* PCAPdroid 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.
*
* PCAPdroid 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 PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-24 - Emanuele Faranda
*/
package com.emanuelef.remote_capture;
import android.annotation.SuppressLint;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import com.emanuelef.remote_capture.activities.MainActivity;
import com.emanuelef.remote_capture.model.CaptureSettings;
/**
* Service which waits for other apps VPNService to terminate before
* restarting the capture.
*/
@RequiresApi(api = Build.VERSION_CODES.M)
public class VpnReconnectService extends Service {
private static final String TAG = "VpnReconnectService";
private static final String NOTIFY_CHAN_VPNRECONNECT = "VPN Reconnection";
public static final int NOTIFY_ID_VPNRECONNECT = 10;
private static final String STOP_ACTION = "stop";
private static VpnReconnectService INSTANCE;
private Handler mHandler;
private ConnectivityManager.NetworkCallback mNetworkCallback;
private Network mActiveVpnNetwork;
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onCreate() {
Log.d(TAG, "onCreate");
mHandler = new Handler(Looper.getMainLooper());
INSTANCE = this;
super.onCreate();
}
@Override
public void onDestroy() {
Log.d(TAG, "onDestroy");
unregisterNetworkCallback();
INSTANCE = null;
super.onDestroy();
}
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
Log.d(TAG, "onStartCommand");
if ((intent != null) && (intent.getAction() != null) && (intent.getAction().equals(STOP_ACTION))) {
Utils.showToastLong(this, R.string.vpn_reconnection_aborted);
stopService();
return START_NOT_STICKY;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
startForeground(NOTIFY_ID_VPNRECONNECT, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
else
startForeground(NOTIFY_ID_VPNRECONNECT, buildNotification());
mHandler.postDelayed(() -> {
Log.i(TAG, "Could not detect a VPN within the timeout, automatic reconnection aborted");
stopService();
}, 10000);
if (!registerNetworkCallbacks()) {
stopService();
return START_NOT_STICKY;
}
return START_STICKY;
}
private Notification buildNotification() {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel chan = new NotificationChannel(NOTIFY_CHAN_VPNRECONNECT,
NOTIFY_CHAN_VPNRECONNECT, NotificationManager.IMPORTANCE_LOW); // low: no sound
chan.setShowBadge(false);
nm.createNotificationChannel(chan);
}
// Status notification builder
PendingIntent startMainApp = PendingIntent.getActivity(this, 0,
new Intent(this, MainActivity.class), Utils.getIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT));
Intent abortReconnectIntent = new Intent(this, VpnReconnectService.class);
abortReconnectIntent.setAction(STOP_ACTION);
PendingIntent abortReconnect = PendingIntent.getService(this, 0, abortReconnectIntent, Utils.getIntentFlags(0));
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFY_CHAN_VPNRECONNECT)
.setSmallIcon(R.drawable.ic_logo)
.setColor(ContextCompat.getColor(this, R.color.colorPrimary))
.setContentIntent(startMainApp)
.setDeleteIntent(abortReconnect)
.setOngoing(true)
.setAutoCancel(false)
.setContentTitle(getString(R.string.vpn_reconnection))
.setContentText(getString(R.string.waiting_for_vpn_disconnect))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW); // see IMPORTANCE_LOW
Log.d(TAG, "running");
return builder.build();
}
private void checkAvailableNetwork(ConnectivityManager cm, Network network) {
if (network.equals(mActiveVpnNetwork))
return;
NetworkCapabilities cap = cm.getNetworkCapabilities(network);
if ((cap != null) && cap.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
mActiveVpnNetwork = network;
Log.d(TAG, "Detected active VPN network: " + mActiveVpnNetwork);
// cancel the deadline timer / onLost timer
mHandler.removeCallbacksAndMessages(null);
}
}
private boolean registerNetworkCallbacks() {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Service.CONNECTIVITY_SERVICE);
mNetworkCallback = new ConnectivityManager.NetworkCallback() {
@Override
public void onAvailable(@NonNull Network network) {
Log.d(TAG, "onAvailable: " + network);
checkAvailableNetwork(cm, network);
}
@Override
public void onLost(@NonNull Network network) {
Log.d(TAG, "onLost: " + network);
// NOTE: when onLost is called, the TRANSPORT_VPN capability may already have been removed
if (network.equals(mActiveVpnNetwork)) {
// NOTE: onAvailable and onLost may be called multiple times before the actual VPN is started.
// Use a debounce delay to prevent mis-detection
mHandler.postDelayed(() -> {
Log.i(TAG, "Active VPN disconnected, starting the capture");
unregisterNetworkCallback();
Context ctx = VpnReconnectService.this;
CaptureSettings settings = new CaptureSettings(ctx, PreferenceManager.getDefaultSharedPreferences(ctx));
CaptureHelper helper = new CaptureHelper(ctx);
helper.setListener(success -> stopService());
helper.startCapture(settings);
}, 3000);
}
}
};
try {
Log.d(TAG, "registerNetworkCallback");
NetworkRequest.Builder builder = new NetworkRequest.Builder()
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN);
// necessary to see other apps network events on Android 12+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
builder.setIncludeOtherUidNetworks(true);
cm.registerNetworkCallback(builder.build(), mNetworkCallback);
} catch (SecurityException e) {
// this is a bug in Android 11 - https://issuetracker.google.com/issues/175055271?pli=1
e.printStackTrace();
Log.e(TAG, "registerNetworkCallback failed");
mNetworkCallback = null;
return false;
}
// The VPN may already be active
Network net = Utils.getRunningVpn(this);
if (net != null)
checkAvailableNetwork(cm, net);
return true;
}
private void unregisterNetworkCallback() {
if(mNetworkCallback != null) {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Service.CONNECTIVITY_SERVICE);
try {
Log.d(TAG, "unregisterNetworkCallback");
cm.unregisterNetworkCallback(mNetworkCallback);
} catch(IllegalArgumentException e) {
Log.w(TAG, "unregisterNetworkCallback failed: " + e);
}
mNetworkCallback = null;
}
}
@SuppressLint("ObsoleteSdkInt")
@RequiresApi(api = Build.VERSION_CODES.BASE)
public static boolean isAvailable() {
return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M;
}
@SuppressWarnings("deprecation")
public static void stopService() {
Log.d(TAG, "stopService called");
VpnReconnectService service = INSTANCE;
if (service == null)
return;
service.unregisterNetworkCallback();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
service.stopForeground(STOP_FOREGROUND_REMOVE);
else
service.stopForeground(true);
service.stopSelf();
}
}

View File

@ -63,6 +63,7 @@ import com.emanuelef.remote_capture.ConnectionsRegister;
import com.emanuelef.remote_capture.Log;
import com.emanuelef.remote_capture.MitmReceiver;
import com.emanuelef.remote_capture.PCAPdroid;
import com.emanuelef.remote_capture.VpnReconnectService;
import com.emanuelef.remote_capture.activities.prefs.SettingsActivity;
import com.emanuelef.remote_capture.fragments.ConnectionsFragment;
import com.emanuelef.remote_capture.fragments.StatusFragment;
@ -85,7 +86,6 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.HashSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@ -720,6 +720,9 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
}
public void startCapture() {
if (VpnReconnectService.isAvailable())
VpnReconnectService.stopService();
if(showRemoteServerAlert())
return;
@ -729,7 +732,7 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
return;
}
if(!Prefs.isRootCaptureEnabled(mPrefs) && Utils.hasVPNRunning(this)) {
if(!Prefs.isRootCaptureEnabled(mPrefs) && (Utils.getRunningVpn(this) != null)) {
new AlertDialog.Builder(this)
.setTitle(R.string.active_vpn_detected)
.setMessage(R.string.disconnect_vpn_confirm)

View File

@ -44,6 +44,7 @@ import com.emanuelef.remote_capture.Log;
import com.emanuelef.remote_capture.PCAPdroid;
import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.MitmAddon;
import com.emanuelef.remote_capture.VpnReconnectService;
import com.emanuelef.remote_capture.activities.BaseActivity;
import com.emanuelef.remote_capture.activities.MainActivity;
import com.emanuelef.remote_capture.activities.MitmSetupWizard;
@ -147,6 +148,7 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
private SwitchPreference mMalwareDetectionEnabled;
private SwitchPreference mTrailerEnabled;
private SwitchPreference mPcapngEnabled;
private SwitchPreference mRestartOnDisconnect;
private Billing mIab;
private boolean mHasStartedMitmWizard;
private boolean mRootDecryptionNoticeShown = false;
@ -266,6 +268,9 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
} else
mRootCaptureEnabled.setVisible(false);
mRestartOnDisconnect = requirePreference(Prefs.PREF_RESTART_ON_DISCONNECT);
mRestartOnDisconnect.setVisible(VpnReconnectService.isAvailable());
mDnsSettings = requirePreference("dns_settings");;
mVpnExceptions = requirePreference(Prefs.PREF_VPN_EXCEPTIONS);
mVpnExceptions.setOnPreferenceClickListener(preference -> {
@ -409,6 +414,9 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
socks5ProxyHideShow(mTlsDecryption.isChecked(), false);
}
if (VpnReconnectService.isAvailable())
mRestartOnDisconnect.setVisible(!enabled);
mIpMode.setVisible(!enabled);
mCapInterface.setVisible(enabled);
mVpnExceptions.setVisible(!enabled);

View File

@ -109,6 +109,7 @@ public class Prefs {
public static final String PREF_DNS_SERVER_V6 = "dns_v6";
public static final String PREF_USE_SYSTEM_DNS = "system_dns";
public static final String PREF_PCAPNG_ENABLED = "pcapng_format";
public static final String PREF_RESTART_ON_DISCONNECT = "restart_on_disconnect";
public enum DumpMode {
NONE,
@ -225,6 +226,7 @@ public class Prefs {
&& p.getBoolean(PREF_PCAPNG_ENABLED, true));
}
public static boolean startAtBoot(SharedPreferences p) { return(p.getBoolean(PREF_START_AT_BOOT, false)); }
public static boolean restartOnDisconnect(SharedPreferences p) { return(p.getBoolean(PREF_RESTART_ON_DISCONNECT, false)); }
public static boolean isTLSDecryptionSetupDone(SharedPreferences p) { return(p.getBoolean(PREF_TLS_DECRYPTION_SETUP_DONE, false)); }
public static boolean getFullPayloadMode(SharedPreferences p) { return(p.getBoolean(PREF_FULL_PAYLOAD, false)); }
public static boolean isPrivateDnsBlockingEnabled(SharedPreferences p) { return(p.getBoolean(PREF_AUTO_BLOCK_PRIVATE_DNS, true)); }

View File

@ -505,4 +505,9 @@
<string name="both">Both</string>
<string name="whats_new">What\'s new</string>
<string name="raw_bytes">Raw bytes</string>
<string name="restart_on_disconnection">Restart on disconnection</string>
<string name="restart_on_disconnection_summary">Automatically restart the capture after being stopped by other VPN apps</string>
<string name="vpn_reconnection">VPN reconnection</string>
<string name="vpn_reconnection_aborted">VPN reconnection aborted</string>
<string name="waiting_for_vpn_disconnect">Waiting for the active VPN to disconnect…</string>
</resources>

View File

@ -153,6 +153,13 @@
app:summary="@string/start_at_boot_summary"
android:defaultValue="false" />
<SwitchPreference
app:key="restart_on_disconnect"
android:title="@string/restart_on_disconnection"
app:iconSpaceReserved="false"
app:summary="@string/restart_on_disconnection_summary"
android:defaultValue="false" />
<SwitchPreference
app:key="pcapdroid_trailer"
app:title="@string/pcapdroid_trailer"