diff --git a/app/build.gradle b/app/build.gradle
index 44b90c2c..c4c6c40d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -80,6 +80,5 @@ dependencies {
// Third-party
implementation 'cat.ereza:customactivityoncrash:2.3.0'
- implementation 'org.nanohttpd:nanohttpd:2.3.1'
implementation 'com.github.KaKaVip:Android-Flag-Kit:v0.1'
}
\ No newline at end of file
diff --git a/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/ChunkedInputStream.java b/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/ChunkedInputStream.java
deleted file mode 100644
index 65283e46..00000000
--- a/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/ChunkedInputStream.java
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
- * 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 .
- *
- * Copyright 2020-21 - Emanuele Faranda
- */
-
-package com.emanuelef.remote_capture.pcap_dump;
-
-import com.emanuelef.remote_capture.CaptureService;
-import com.emanuelef.remote_capture.Utils;
-
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.concurrent.locks.Condition;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-
-/* Provides an input stream to read data from bytes chunks produced
- asynchronously via produceData(). bytes[] chunks are used instead of a
- single bytes[] in order to avoid excessive data copies.
- */
-public class ChunkedInputStream extends InputStream {
- final Lock mLock = new ReentrantLock();
- final Condition newData = mLock.newCondition();
- ArrayList mChunks = new ArrayList();
- int mCurChunkIndex = 0;
- boolean hasFinished = false;
-
- public ChunkedInputStream() {
- // Send the PCAP header as the first chunk
- mChunks.add(CaptureService.getPcapHeader());
- }
-
- /* Mark the termination of stream */
- public void stop() {
- mLock.lock();
-
- try {
- hasFinished = true;
- newData.signal();
- } finally {
- mLock.unlock();
- }
- }
-
- /* Produce data to be read from the stream */
- public void produceData(byte data[]) {
- mLock.lock();
- try {
- if(hasFinished)
- return;
-
- mChunks.add(data);
- newData.signal();
- } finally {
- mLock.unlock();
- }
- }
-
- @Override
- public int read(byte[] buf, int off, int maxlen) {
- int out_size = 0;
-
- if(maxlen <= 0)
- return(0);
-
- mLock.lock();
- try {
- /* Possibly wait for new data */
- while((!hasFinished) && (mChunks.size() == 0))
- newData.await();
-
- if(mChunks.size() > 0) {
- /* At least one byte will be returned here. Do not call await() below,
- just return the available bytes to provide a more responsive transfer. */
-
- while((mChunks.size() > 0) && (maxlen > 0)) {
- byte[] chunk = mChunks.get(0);
-
- if(off > 0) {
- // skip bytes due to the offset
- int toSkip = Math.min(off, chunk.length - mCurChunkIndex);
- off -= toSkip;
- mCurChunkIndex += toSkip;
- }
-
- if (mCurChunkIndex < chunk.length) {
- int copy_length = Math.min(maxlen, chunk.length - mCurChunkIndex);
- System.arraycopy(chunk, mCurChunkIndex, buf, out_size, copy_length);
- out_size += copy_length;
- mCurChunkIndex += copy_length;
- maxlen -= copy_length;
- }
-
- if (mCurChunkIndex >= chunk.length) {
- // next chunk
- mChunks.remove(0);
- mCurChunkIndex = 0;
- }
- }
-
- return(out_size);
- }
-
- /* Should be reached when hasFinished is set */
- return(-1);
- } catch (InterruptedException e) {
- return(-1);
- } finally {
- mLock.unlock();
- }
- }
-
- @Override
- public int read() {
- byte[] buf = new byte[1];
- int rv = read(buf, 0, 1);
-
- if(rv == -1)
- return(-1);
- else
- return(buf[0]);
- }
-}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/HTTPServer.java b/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/HTTPServer.java
index b5d9fe9e..28fbba1d 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/HTTPServer.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/HTTPServer.java
@@ -14,96 +14,326 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see .
*
- * Copyright 2020-21 - Emanuele Faranda
+ * Copyright 2020-22 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.pcap_dump;
import android.content.Context;
+import android.util.Log;
-import com.emanuelef.remote_capture.R;
+import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.interfaces.PcapDumper;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.Closeable;
+import java.io.FilterOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.StringTokenizer;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
-import fi.iki.elonen.NanoHTTPD;
-import fi.iki.elonen.NanoHTTPD.Response.Status;
-
-public class HTTPServer extends NanoHTTPD implements PcapDumper {
+/*
+ * A simple HTTP server which allows clients to download the PCAP dump over HTTP.
+ */
+public class HTTPServer implements PcapDumper, Runnable {
+ private static final String TAG = "HTTPServer";
private static final String PCAP_MIME = "application/vnd.tcpdump.pcap";
- private boolean mAcceptConnections = false;
- private int mPort;
+ public static final int MAX_CLIENTS = 8;
+ private ServerSocket mSocket;
+ private boolean mRunning;
+ private Thread mThread;
+ private final int mPort;
private final Context mContext;
- /* NOTE: access to mActiveResponses must be synchronized */
- private final ArrayList mActiveResponses = new ArrayList<>();
+ // Shared state, must be synchronized
+ private final ArrayList mClients = new ArrayList<>();
public HTTPServer(Context context, int port) {
- super(port);
mPort = port;
mContext = context;
}
- private Response redirectToPcap() {
- String fname = Utils.getUniquePcapFileName(mContext);
- Response r = newFixedLengthResponse(Status.TEMPORARY_REDIRECT, MIME_HTML, "");
- r.addHeader("Location", "/" + fname);
- return(r);
- }
-
- /* Creates a new Response and add it to the active responses.
- * NOTE: socket protect not needed for inbound connections. */
- private synchronized Response newPcapStream() {
- /* NOTE: response length is unknown */
- Response res = newChunkedResponse(Status.OK, PCAP_MIME, new ChunkedInputStream());
-
- mActiveResponses.add(res);
-
- return res;
- }
-
- @Override
- public Response serve(IHTTPSession session) {
- if(!mAcceptConnections)
- return newFixedLengthResponse(Status.FORBIDDEN, MIME_PLAINTEXT,
- mContext.getString(R.string.start_capture_first));
-
- if(session.getUri().endsWith("/")) {
- /* Use a redirect to provide a file name */
- return redirectToPcap();
+ private static class ChunkedOutputStream extends FilterOutputStream {
+ public ChunkedOutputStream(OutputStream out) throws IOException {
+ super(out);
}
- return newPcapStream();
+ @Override
+ public void write(byte[] data) throws IOException {
+ // Chunked transfer coding
+ // https://datatracker.ietf.org/doc/html/rfc2616#section-3.6.1
+ out.write(String.format("%x\r\n", data.length).getBytes());
+ out.write(data);
+ out.write("\r\n".getBytes());
+ out.flush();
+ }
+
+ public void finish() throws IOException {
+ // Chunked transfer termination
+ out.write("0\r\n\r\n".getBytes());
+ }
+ }
+
+ /* Handles a single HTTP client. The normal workflow is:
+ * 1. if isReadyForData then sendChunk
+ * 2. if isClosed then remove this client
+ *
+ * No need for synchronization because sendChunk is only called when the runnable has terminated
+ * (see isReadyForData).
+ */
+ private static class ClientHandler implements Runnable {
+ static final int INPUT_BUFSIZE = 1024;
+ Socket mSocket;
+ final InputStream mInputStream;
+ final OutputStream mOutputStream;
+ final String mFname;
+ ChunkedOutputStream mChunkedOutputStream;
+ boolean mHasError;
+ boolean mReadyForData;
+ boolean mHeaderSent;
+ boolean mIsClosed;
+
+ public ClientHandler(Socket socket, String fname) throws IOException {
+ mSocket = socket;
+ mFname = fname;
+ mInputStream = mSocket.getInputStream();
+ mOutputStream = mSocket.getOutputStream();
+ }
+
+ /* Detects and returns the end of the HTTP request headers */
+ private int getEndOfRequestHeaders(byte[] buf) {
+ for(int i = 0; i < (buf.length - 4); i++) {
+ if((buf[i] == '\r') && (buf[i+1] == '\n') && (buf[i+2] == '\r') && (buf[i+3] == '\n'))
+ return i+4;
+ }
+ return 0;
+ }
+
+ private void close(String error) {
+ if(error != null) {
+ Log.i(TAG, "Client error: " + error);
+ mHasError = true;
+ } else if (mReadyForData) {
+ try {
+ // Terminate the chunked stream
+ mChunkedOutputStream.finish();
+ } catch (IOException ignored) {}
+ }
+
+ checkedClose(mChunkedOutputStream);
+ checkedClose(mOutputStream);
+ checkedClose(mInputStream);
+ checkedClose(mSocket);
+ mIsClosed = true;
+ }
+
+ public void stop() {
+ // if active, will trigger a IOException
+ checkedClose(mSocket);
+ }
+
+ @Override
+ public void run() {
+ byte[] buf = new byte[INPUT_BUFSIZE];
+ int sofar = 0;
+ int req_size = 0;
+
+ try {
+ while(req_size <= 0) {
+ sofar += mInputStream.read(buf, sofar, buf.length - sofar);
+ req_size = getEndOfRequestHeaders(buf);
+ }
+
+ Log.d(TAG, "Request headers end at " + req_size);
+ //Log.d(TAG, "Req: " + new String(buf, 0, req_size, StandardCharsets.UTF_8));
+
+ try(BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, req_size)))) {
+ String line = reader.readLine();
+ if(line == null) {
+ close("Bad request");
+ return;
+ }
+
+ StringTokenizer tk = new StringTokenizer(line);
+ String method = tk.nextToken();
+ String url = tk.nextToken();
+
+ if(!method.equals("GET")) {
+ close("Bad request method");
+ return;
+ }
+
+ if(url.equals("/")) {
+ redirectToPcap();
+ close(null);
+ } else {
+ Log.d(TAG, "URL: " + url);
+
+ mOutputStream.write(("HTTP/1.1 200 OK\r\n" +
+ "Content-Type: " + PCAP_MIME + "\r\n" +
+ //"Content-Encoding: gzip\r\n" + // TODO?
+ "Connection: close\r\n" +
+ "Transfer-Encoding: chunked\r\n" +
+ "\r\n"
+ ).getBytes());
+ mOutputStream.flush();
+
+ Log.d(TAG, "Ready for data");
+ mChunkedOutputStream = new ChunkedOutputStream(mOutputStream);
+ mReadyForData = true;
+ }
+ }
+ } catch (IOException | NoSuchElementException e) {
+ close(e.getLocalizedMessage());
+ }
+ }
+
+ /* Sends a 302 redirect to allow saving the PCAP file with a specific name */
+ private void redirectToPcap() throws IOException {
+ Log.d(TAG, "Redirecting to PCAP: " + mFname);
+
+ mOutputStream.write(("HTTP/1.1 302 Found\r\n" +
+ "Location: /" + mFname + "\r\n" +
+ "\r\n"
+ ).getBytes());
+ }
+
+ // Returns true if the client socket is closed
+ public boolean isClosed() {
+ return mIsClosed;
+ }
+
+ public boolean isReadyForData() {
+ return mReadyForData;
+ }
+
+ // Send a chunk of data
+ public void sendChunk(byte []data) {
+ try {
+ if(!mHeaderSent) {
+ mChunkedOutputStream.write(CaptureService.getPcapHeader());
+ mHeaderSent = true;
+ }
+
+ //Log.d(TAG, "+CHUNK [" + data.length + "]");
+ mChunkedOutputStream.write(data);
+ } catch (IOException e) {
+ close(e.getLocalizedMessage());
+ }
+ }
}
@Override
public void startDumper() throws IOException {
- mAcceptConnections = true;
- start();
+ mSocket = new ServerSocket();
+ mSocket.setReuseAddress(true);
+ mSocket.bind(new InetSocketAddress(mPort));
+
+ mRunning = true;
+ mThread = new Thread(this);
+ mThread.start();
+ }
+
+ @Override
+ public void run() {
+ // NOTE: threads only handle the initial client communication.
+ // After isReadyForData, clients are handled in dumpData.
+ ExecutorService pool = Executors.newFixedThreadPool(MAX_CLIENTS);
+
+ while(mRunning) {
+ try {
+ Socket client = mSocket.accept();
+
+ synchronized(this) {
+ if(mClients.size() >= MAX_CLIENTS) {
+ Log.w(TAG, "Clients limit reached");
+ checkedClose(client);
+ continue;
+ }
+ }
+
+ Log.i(TAG, "New client: " + client.getInetAddress().getHostAddress() + ":" + client.getPort());
+ ClientHandler handler = new ClientHandler(client, Utils.getUniquePcapFileName(mContext));
+
+ try {
+ // will fail if pool is full
+ pool.submit(handler);
+
+ synchronized(this) {
+ mClients.add(handler);
+ }
+ } catch (RejectedExecutionException e) {
+ Log.w(TAG, e.getLocalizedMessage());
+ checkedClose(client);
+ }
+ } catch (IOException e) {
+ if(!mRunning)
+ Log.d(TAG, "Got termination request");
+ else
+ Log.d(TAG, e.getLocalizedMessage());
+ }
+ }
+
+ checkedClose(mSocket);
+
+ // Terminate the clients
+ pool.shutdown();
+ synchronized(this) {
+ for(ClientHandler client: mClients)
+ client.close(null);
+
+ mClients.clear();
+ }
+
+ // Wait clients termination
+ while(true) {
+ try {
+ if(pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS))
+ break;
+ } catch (InterruptedException ignored) {}
+ }
+ }
+
+ private static void checkedClose(Closeable socket) {
+ if(socket == null)
+ return;
+
+ try {
+ socket.close();
+ } catch (IOException e) {
+ Log.d(TAG, e.getLocalizedMessage());
+ }
}
@Override
public void stopDumper() throws IOException {
- synchronized (this) {
- for (int i = mActiveResponses.size() - 1; i >= 0; i--) {
- Response res = mActiveResponses.get(i);
+ mRunning = false;
- if (res.isCloseConnection()) {
- /* Cleanup closed connections */
- mActiveResponses.remove(i);
- continue;
- }
+ // Generate a socket exception
+ mSocket.close();
- ((ChunkedInputStream) res.getData()).stop();
+ while((mThread != null) && (mThread.isAlive())) {
+ try {
+ Log.d(TAG, "Joining HTTP thread...");
+ mThread.join();
+ } catch (InterruptedException e) {
+ Log.e(TAG, "Joining HTTP thread failed");
}
-
- mActiveResponses.clear();
- mAcceptConnections = false;
}
-
- stop();
}
@Override
@@ -113,18 +343,19 @@ public class HTTPServer extends NanoHTTPD implements PcapDumper {
@Override
public void dumpData(byte[] data) throws IOException {
- synchronized (this) {
- /* Dispatch PCAP data to the active connections */
- for (int i = mActiveResponses.size() - 1; i >= 0; i--) {
- Response res = mActiveResponses.get(i);
+ synchronized(this) {
+ Iterator it = mClients.iterator();
- if (res.isCloseConnection()) {
- /* Cleanup closed connections */
- mActiveResponses.remove(i);
- continue;
+ while(it.hasNext()) {
+ ClientHandler client = it.next();
+
+ if(client.isReadyForData())
+ client.sendChunk(data);
+
+ if(client.isClosed()) {
+ it.remove();
+ Log.d(TAG, "Client closed, active clients: " + mClients.size());
}
-
- ((ChunkedInputStream) res.getData()).produceData(data);
}
}
}