mirror of
https://github.com/emanuele-f/PCAPdroid.git
synced 2026-06-16 21:10:57 +08:00
Fix memory leak in HTTP server mode
When a client stopped the PCAP download, the isCloseConnection did not detect the close. As a result, the PCAP dump would still be enqueued to the ChunkedInputStream, causing the memory allocation to raise and never be free. Since NanoHTTPD does not seem to provide a way to detect connection close and since it is not actively maintained, the HTTP server has been replaced with an ad-hoc implementation which provides the minimal features PCAPdroid needs to export the PCAP over HTTP.
This commit is contained in:
parent
e6906f3603
commit
18ffc2c374
@ -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'
|
||||
}
|
||||
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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<byte[]> mChunks = new ArrayList<byte[]>();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@ -14,96 +14,326 @@
|
||||
* 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-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<Response> mActiveResponses = new ArrayList<>();
|
||||
// Shared state, must be synchronized
|
||||
private final ArrayList<ClientHandler> 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<ClientHandler> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user