diff --git a/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java b/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java index 12c76180..7433432d 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java +++ b/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java @@ -25,6 +25,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.ConnectivityManager; +import android.net.Uri; import android.net.VpnService; import android.os.Build; import android.os.Bundle; @@ -41,7 +42,9 @@ import com.emanuelef.remote_capture.model.ConnectionDescriptor; import com.emanuelef.remote_capture.model.Prefs; import com.emanuelef.remote_capture.model.VPNStats; +import java.io.FileNotFoundException; import java.io.IOException; +import java.io.OutputStream; import java.net.InetSocketAddress; public class CaptureService extends VpnService implements Runnable { @@ -64,7 +67,10 @@ public class CaptureService extends VpnService implements Runnable { private static CaptureService INSTANCE; private String app_filter; private HTTPServer mHttpServer; + private OutputStream mOutputStream; private ConnectionsRegister conn_reg; + private Uri mPcapUri; + private boolean mFirstStreamWrite; /* The maximum connections to log into the ConnectionsRegister. Older connections are dropped. * Max Estimated max memory usage: less than 2 MB. */ @@ -140,6 +146,11 @@ public class CaptureService extends VpnService implements Runnable { conn_reg = new ConnectionsRegister(CONNECTIONS_LOG_SIZE); + if(dump_mode != Prefs.DumpMode.HTTP_SERVER) + mHttpServer = null; + mOutputStream = null; + mPcapUri = null; + if(dump_mode == Prefs.DumpMode.HTTP_SERVER) { if (mHttpServer == null) mHttpServer = new HTTPServer(app_ctx, http_server_port); @@ -150,8 +161,25 @@ public class CaptureService extends VpnService implements Runnable { Log.e(CaptureService.TAG, "Could not start the HTTP server"); e.printStackTrace(); } - } else - mHttpServer = null; + } else if(dump_mode == Prefs.DumpMode.PCAP_FILE) { + String path = settings.getString(Prefs.PREF_PCAP_URI); + + if(path != null) { + mPcapUri = Uri.parse(path); + + try { + mOutputStream = getContentResolver().openOutputStream(mPcapUri); + mFirstStreamWrite = true; + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + + if(mOutputStream == null) { + Utils.showToast(this, R.string.cannot_write_pcap_file); + return super.onStartCommand(intent, flags, startId); + } + } Log.i(TAG, "Using DNS server " + public_dns); @@ -244,6 +272,16 @@ public class CaptureService extends VpnService implements Runnable { if(mHttpServer != null) mHttpServer.endConnections(); // NOTE: do not destroy the mHttpServer, let it terminate the active connections + + if(mOutputStream != null) { + try { + mOutputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + mPcapUri = null; } /* Check if the VPN service was launched */ @@ -256,6 +294,10 @@ public class CaptureService extends VpnService implements Runnable { return((INSTANCE != null) ? INSTANCE.app_filter : null); } + public static Uri getPcapUri() { + return ((INSTANCE != null) ? INSTANCE.mPcapUri : null); + } + public static long getBytes() { return((INSTANCE != null) ? INSTANCE.last_bytes : 0); } @@ -334,7 +376,7 @@ public class CaptureService extends VpnService implements Runnable { // returns 1 if dumpPcapData should be called public int dumpPcapToJava() { - return((mHttpServer != null) ? 1 : 0); + return(((dump_mode == Prefs.DumpMode.HTTP_SERVER) || (dump_mode == Prefs.DumpMode.PCAP_FILE)) ? 1 : 0); } public int dumpPcapToUdp() { @@ -410,6 +452,20 @@ public class CaptureService extends VpnService implements Runnable { public void dumpPcapData(byte[] data) { if(mHttpServer != null) mHttpServer.pushData(data); + else if(mOutputStream != null) { + try { + if(mFirstStreamWrite) { + mOutputStream.write(Utils.hexStringToByteArray(Utils.PCAP_HEADER)); + mFirstStreamWrite = false; + } + + mOutputStream.write(data); + } catch (IOException e) { + e.printStackTrace(); + reportError(e.getLocalizedMessage()); + stopPacketLoop(); + } + } } public void reportError(String msg) { diff --git a/app/src/main/java/com/emanuelef/remote_capture/ChunkedInputStream.java b/app/src/main/java/com/emanuelef/remote_capture/ChunkedInputStream.java index efd6616d..9b757a5b 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/ChunkedInputStream.java +++ b/app/src/main/java/com/emanuelef/remote_capture/ChunkedInputStream.java @@ -30,7 +30,7 @@ import java.util.concurrent.locks.ReentrantLock; single bytes[] in order to avoid excessive data copies. */ class ChunkedInputStream extends InputStream { - private static final byte[] pcapHeader = Utils.hexStringToByteArray("d4c3b2a1020004000000000000000000ffff000065000000"); + private static final byte[] pcapHeader = Utils.hexStringToByteArray(Utils.PCAP_HEADER); final Lock mLock = new ReentrantLock(); final Condition newData = mLock.newCondition(); ArrayList mChunks = new ArrayList(); diff --git a/app/src/main/java/com/emanuelef/remote_capture/HTTPServer.java b/app/src/main/java/com/emanuelef/remote_capture/HTTPServer.java index a687a26e..5f331943 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/HTTPServer.java +++ b/app/src/main/java/com/emanuelef/remote_capture/HTTPServer.java @@ -20,20 +20,15 @@ package com.emanuelef.remote_capture; import android.content.Context; -import android.util.Log; import java.io.IOException; -import java.text.DateFormat; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; import fi.iki.elonen.NanoHTTPD; import fi.iki.elonen.NanoHTTPD.Response.Status; public class HTTPServer extends NanoHTTPD { private static final String PCAP_MIME = "application/vnd.tcpdump.pcap"; - private final DateFormat mFmt = new SimpleDateFormat("HH_mm_ss"); private boolean firstStart = true; private boolean mAcceptConnections = false; private final Context mContext; @@ -47,7 +42,7 @@ public class HTTPServer extends NanoHTTPD { } private Response redirectToPcap() { - String fname = "PCAPdroid_" + mFmt.format(new Date()) + ".pcap"; + String fname = Utils.getUniquePcapFileName(mContext); Response r = newFixedLengthResponse(Status.TEMPORARY_REDIRECT, MIME_HTML, ""); r.addHeader("Location", "/" + fname); return(r); diff --git a/app/src/main/java/com/emanuelef/remote_capture/Utils.java b/app/src/main/java/com/emanuelef/remote_capture/Utils.java index bc8c6cdf..01f942c5 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/Utils.java +++ b/app/src/main/java/com/emanuelef/remote_capture/Utils.java @@ -46,12 +46,17 @@ import java.net.InetAddress; import java.net.NetworkInterface; import java.net.UnknownHostException; import java.nio.ByteOrder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Locale; public class Utils { + public static final String PCAP_HEADER = "d4c3b2a1020004000000000000000000ffff000065000000"; + public static String formatBytes(long bytes) { long divisor; String suffix; @@ -269,4 +274,10 @@ public class Utils { alert.show(); } + + public static String getUniquePcapFileName(Context context) { + Locale locale = context.getResources().getConfiguration().locale; + final DateFormat fmt = new SimpleDateFormat("dd_MMM_HH_mm_ss", locale); + return "PCAPdroid_" + fmt.format(new Date()) + ".pcap"; + } } diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java index 946c2648..bf154b49 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java +++ b/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java @@ -26,6 +26,7 @@ import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; +import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; import android.net.VpnService; @@ -41,6 +42,8 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; import android.os.Bundle; +import android.provider.DocumentsContract; +import android.provider.OpenableColumns; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; @@ -59,6 +62,7 @@ import com.emanuelef.remote_capture.R; import com.emanuelef.remote_capture.Utils; import com.google.android.material.navigation.NavigationView; +import java.io.FileNotFoundException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -68,22 +72,25 @@ import java.util.Objects; import cat.ereza.customactivityoncrash.config.CaocConfig; public class MainActivity extends AppCompatActivity implements AppsLoadListener, NavigationView.OnNavigationItemSelectedListener { - SharedPreferences mPrefs; - Menu mMenu; - MenuItem mMenuItemStartBtn; - MenuItem mMenuItemAppSel; - MenuItem mMenuSettings; - Drawable mFilterIcon; - String mFilterApp; - boolean mOpenAppsWhenDone; - List mInstalledApps; - AppState mState; - AppStateListener mListener; - AppDescriptor mNoFilterApp; + private SharedPreferences mPrefs; + private Menu mMenu; + private MenuItem mMenuItemStartBtn; + private MenuItem mMenuItemAppSel; + private MenuItem mMenuSettings; + private Drawable mFilterIcon; + private String mFilterApp; + private boolean mOpenAppsWhenDone; + private List mInstalledApps; + private AppState mState; + private AppStateListener mListener; + private AppDescriptor mNoFilterApp; + private Uri mPcapUri; + private BroadcastReceiver mReceiver; private static final String TAG = "Main"; private static final int REQUEST_CODE_VPN = 2; + private static final int REQUEST_CODE_PCAP_FILE = 3; public static final String TELEGRAM_GROUP_NAME = "PCAPdroid"; public static final String GITHUB_PROJECT_URL = "https://github.com/emanuele-f/PCAPdroid"; @@ -97,6 +104,7 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, mNoFilterApp = new AppDescriptor("", icon, this.getResources().getString(R.string.no_filter), -1, false, true); mFilterApp = CaptureService.getAppFilter(); + mPcapUri = CaptureService.getPcapUri(); if((mFilterApp == null) && (savedInstanceState != null)) { // Possibly get the temporary filter @@ -117,10 +125,8 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, .setAppsLoadListener(this) .loadAllApps(); - LocalBroadcastManager bcast_man = LocalBroadcastManager.getInstance(this); - /* Register for service status */ - bcast_man.registerReceiver(new BroadcastReceiver() { + mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String status = intent.getStringExtra(CaptureService.SERVICE_STATUS_KEY); @@ -133,11 +139,26 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, if (CaptureService.isServiceActive()) CaptureService.stopService(); + if((mPcapUri != null) && (Prefs.getDumpMode(mPrefs) == Prefs.DumpMode.PCAP_FILE)) { + showPcapActionDialog(mPcapUri); + mPcapUri = null; + } + appStateReady(); } } } - }, new IntentFilter(CaptureService.ACTION_SERVICE_STATUS)); + }; + + LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, new IntentFilter(CaptureService.ACTION_SERVICE_STATUS)); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if(mReceiver != null) + LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver); } @Override @@ -383,6 +404,9 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, Intent intent = new Intent(MainActivity.this, CaptureService.class); Bundle bundle = new Bundle(); + if((mPcapUri != null) && (Prefs.getDumpMode(mPrefs) == Prefs.DumpMode.PCAP_FILE)) + bundle.putString(Prefs.PREF_PCAP_URI, mPcapUri.toString()); + bundle.putString(Prefs.PREF_APP_FILTER, mFilterApp); intent.putExtra("settings", bundle); @@ -393,6 +417,14 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, Log.w(TAG, "VPN request failed"); appStateReady(); } + } else if(requestCode == REQUEST_CODE_PCAP_FILE) { + if(resultCode == RESULT_OK) { + mPcapUri = data.getData(); + Log.d(TAG, "PCAP to write: " + mPcapUri.toString()); + + toggleService(); + } else + mPcapUri = null; } } @@ -421,6 +453,11 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, appStateStopping(); CaptureService.stopService(); } else { + if((mPcapUri == null) && (Prefs.getDumpMode(mPrefs) == Prefs.DumpMode.PCAP_FILE)) { + openFileSelector(); + return; + } + if(Utils.hasVPNRunning(this)) { new AlertDialog.Builder(this) .setMessage(R.string.existing_vpn_confirm) @@ -432,6 +469,77 @@ public class MainActivity extends AppCompatActivity implements AppsLoadListener, } } + public void openFileSelector() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/cap"); + intent.putExtra(Intent.EXTRA_TITLE, Utils.getUniquePcapFileName(this)); + + startActivityForResult(intent, REQUEST_CODE_PCAP_FILE); + } + + public void showPcapActionDialog(Uri pcapUri) { + Cursor cursor; + + try { + cursor = getContentResolver().query(pcapUri, null, null, null, null); + } catch (Exception e) { + return; + } + + if((cursor == null) || !cursor.moveToFirst()) + return; + + // If file is empty, delete it + long file_size = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE)); + String fname = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + cursor.close(); + + if(file_size == 0) { + Log.d(TAG, "PCAP file is empty, deleting"); + + try { + DocumentsContract.deleteDocument(getContentResolver(), pcapUri); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + + return; + } + + String message = String.format(getResources().getString(R.string.pcap_file_action), fname, Utils.formatBytes(file_size)); + + AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this); + builder.setMessage(message); + + builder.setPositiveButton(R.string.share, (dialog, which) -> { + Intent sendIntent = new Intent(Intent.ACTION_SEND); + sendIntent.setType("application/cap"); + sendIntent.putExtra(Intent.EXTRA_STREAM, pcapUri); + startActivity(Intent.createChooser(sendIntent, getResources().getString(R.string.share))); + }); + builder.setNegativeButton(R.string.delete, (dialog, which) -> { + Log.d(TAG, "Deleting PCAP file" + pcapUri.getPath()); + boolean deleted = false; + + try { + deleted = DocumentsContract.deleteDocument(getContentResolver(), pcapUri); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + + if(!deleted) + Utils.showToast(MainActivity.this, R.string.delete_error); + + dialog.cancel(); + }); + builder.setNeutralButton(R.string.ok, (dialog, which) -> { + dialog.cancel(); + }); + + builder.create().show(); + } + public AppState getState() { return(mState); } diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/SettingsActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/SettingsActivity.java index 1b8438ed..d97b81fc 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/activities/SettingsActivity.java +++ b/app/src/main/java/com/emanuelef/remote_capture/activities/SettingsActivity.java @@ -146,6 +146,9 @@ public class SettingsActivity extends AppCompatActivity { case HTTP_SERVER: summary_id = R.string.http_server_info; break; + case PCAP_FILE: + summary_id = R.string.pcap_file_info; + break; case UDP_EXPORTER: summary_id = R.string.udp_exporter_info; break; diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java index 5aaff2ed..91323f70 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java +++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java @@ -37,6 +37,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; @@ -51,8 +52,6 @@ import com.emanuelef.remote_capture.activities.MainActivity; import com.emanuelef.remote_capture.activities.StatsActivity; import com.emanuelef.remote_capture.interfaces.AppStateListener; -import org.w3c.dom.Text; - public class StatusFragment extends Fragment implements AppStateListener { private TextView mCollectorInfo; private TextView mCaptureStatus; @@ -120,10 +119,10 @@ public class StatusFragment extends Fragment implements AppStateListener { } private void setupInspectorLinK() { - int color = getResources().getColor(android.R.color.tab_indicator_text); + int color = ContextCompat.getColor(mActivity, android.R.color.tab_indicator_text); mInspectorLink.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_search, 0, 0, 0); - Drawable drawables[] = mInspectorLink.getCompoundDrawables(); + Drawable []drawables = mInspectorLink.getCompoundDrawables(); drawables[0].setColorFilter(color, PorterDuff.Mode.SRC_IN); mInspectorLink.setOnClickListener(v -> { @@ -156,6 +155,10 @@ public class StatusFragment extends Fragment implements AppStateListener { info = String.format(getResources().getString(R.string.http_server_status), Utils.getLocalIPAddress(mActivity), CaptureService.getHTTPServerPort()); break; + case PCAP_FILE: + modeName = getResources().getString(R.string.pcap_file); + info = ""; + break; case UDP_EXPORTER: modeName = getResources().getString(R.string.udp_exporter); info = String.format(getResources().getString(R.string.collector_info), diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java b/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java index 2180f8f6..d02ac85d 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java +++ b/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java @@ -24,6 +24,7 @@ import android.content.SharedPreferences; public class Prefs { public static final String DUMP_HTTP_SERVER = "http_server"; public static final String DUMP_UDP_EXPORTER = "udp_exporter"; + public static final String DUMP_PCAP_FILE = "pcap_file"; public static final String PREF_COLLECTOR_IP_KEY = "collector_ip_address"; public static final String PREF_COLLECTOR_PORT_KEY = "collector_port"; public static final String PREF_TLS_PROXY_IP_KEY = "tls_proxy_ip_address"; @@ -32,16 +33,20 @@ public class Prefs { public static final String PREF_APP_FILTER = "app_filter"; public static final String PREF_HTTP_SERVER_PORT = "http_server_port"; public static final String PREF_PCAP_DUMP_MODE = "pcap_dump_mode"; + public static final String PREF_PCAP_URI = "pcap_path"; public enum DumpMode { NONE, HTTP_SERVER, + PCAP_FILE, UDP_EXPORTER } public static DumpMode getDumpMode(String pref) { if(pref.equals(DUMP_HTTP_SERVER)) return(DumpMode.HTTP_SERVER); + else if(pref.equals(DUMP_PCAP_FILE)) + return(DumpMode.PCAP_FILE); else if(pref.equals(DUMP_UDP_EXPORTER)) return(DumpMode.UDP_EXPORTER); else diff --git a/app/src/main/jni/vpnproxy-jni/vpnproxy.c b/app/src/main/jni/vpnproxy-jni/vpnproxy.c index a54685b6..0135cd56 100644 --- a/app/src/main/jni/vpnproxy-jni/vpnproxy.c +++ b/app/src/main/jni/vpnproxy-jni/vpnproxy.c @@ -371,7 +371,7 @@ static void process_ndpi_packet(conn_data_t *data, vpnproxy_data_t *proxy, const /* ******************************************************* */ -static void javaPcapDump(zdtun_t *tun, vpnproxy_data_t *proxy) { +static void javaPcapDump(vpnproxy_data_t *proxy) { JNIEnv *env = proxy->env; log_android(ANDROID_LOG_DEBUG, "Exporting a %d B PCAP buffer", proxy->java_dump.buffer_idx); @@ -470,7 +470,7 @@ static void account_packet(zdtun_t *tun, const char *packet, int size, uint8_t f if((JAVA_PCAP_BUFFER_SIZE - proxy->java_dump.buffer_idx) <= tot_size) { // Flush the buffer - javaPcapDump(tun, proxy); + javaPcapDump(proxy); } if((JAVA_PCAP_BUFFER_SIZE - proxy->java_dump.buffer_idx) <= tot_size) @@ -1108,7 +1108,7 @@ housekeeping: last_connections_dump = now_ms; } else if((proxy.java_dump.buffer_idx > 0) && (now_ms - proxy.java_dump.last_dump_ms) >= MAX_JAVA_DUMP_DELAY_MS) { - javaPcapDump(tun, &proxy); + javaPcapDump(&proxy); } else if((now_ms >= next_purge_ms) || dump_vpn_stats_now) { dump_vpn_stats_now = false; zdtun_statistics_t stats; @@ -1135,6 +1135,9 @@ housekeeping: } if(proxy.java_dump.buffer) { + if(proxy.java_dump.buffer_idx > 0) + javaPcapDump(&proxy); + free(proxy.java_dump.buffer); proxy.java_dump.buffer = NULL; } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index b7f82990..eb4f19ac 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3,12 +3,14 @@ none http_server + pcap_file udp_exporter @string/no_dump @string/http_server + @string/pcap_file @string/udp_exporter diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d9155e4..900ebb8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,5 +89,13 @@ Drawer Open Drawer Close Inspector + PCAP File + Create a PCAP file into the device storage + Cannot write PCAP file + Share + Delete + Ok + Traffic saved to file %1$s (%2$s). + Could not delete file