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); } } }