/* * Copyright (c) 2013, Psiphon Inc. * All rights reserved. * * This program 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. * * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. * */ package ca.psiphon.ploggy; import java.io.IOException; import java.io.InputStream; import java.net.ServerSocket; import java.net.Socket; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.util.List; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; import android.util.Pair; import fi.iki.elonen.NanoHTTPD; /** * Wrapper for NanoHTTPD embedded web server, which is used as the server-side for Ploggy friend-to-friend * requests. * * Uses TLS configured with TransportSecurity specs and mutual authentication. Web clients must present a * valid friend certificate. Uses the Engine thread pool to service web requests. */ public class WebServer extends NanoHTTPD implements NanoHTTPD.ServerSocketFactory, NanoHTTPD.AsyncRunner { private static final String LOG_TAG = "Web Server"; private static final int READ_TIMEOUT_MILLISECONDS = 60000; public interface RequestHandler { public static class DownloadResponse { public final boolean mAvailable; public final String mMimeType; public final InputStream mData; public DownloadResponse(boolean available, String mimeType, InputStream data) { mAvailable = available; mMimeType = mimeType; mData = data; } } public void submitWebRequestTask(Runnable task); public Data.Status handlePullStatusRequest(String friendId) throws Utils.ApplicationError; public void handlePushStatusRequest(String friendId, Data.Status status) throws Utils.ApplicationError; public DownloadResponse handleDownloadRequest(String friendCertificate, String resourceId, Pair<Long, Long> range) throws Utils.ApplicationError; } private final RequestHandler mRequestHandler; private final X509.KeyMaterial mX509KeyMaterial; private final List<String> mFriendCertificates; public WebServer( RequestHandler requestHandler, X509.KeyMaterial x509KeyMaterial, List<String> friendCertificates) throws Utils.ApplicationError { // Bind to loopback only -- not a public web server. Also, specify port 0 to let // the system pick any available port for listening. super("127.0.0.1", 0); mRequestHandler = requestHandler; mX509KeyMaterial = x509KeyMaterial; mFriendCertificates = friendCertificates; setServerSocketFactory(this); setAsyncRunner(this); } @Override public ServerSocket createServerSocket() throws IOException { try { SSLServerSocket sslServerSocket = (SSLServerSocket)TransportSecurity.makeServerSocket(mX509KeyMaterial, mFriendCertificates); return sslServerSocket; } catch (Utils.ApplicationError e) { throw new IOException(e); } } @Override protected int getReadTimeout() { return READ_TIMEOUT_MILLISECONDS; } @Override public void exec(Runnable webRequestTask) { // TODO: verify that either InterruptedException is thrown, or check Thread.isInterrupted(), in NanoHTTPD request handling Runnables Log.addEntry(LOG_TAG, "got web request"); mRequestHandler.submitWebRequestTask(webRequestTask); } private String getPeerCertificate(Socket socket) throws Utils.ApplicationError { // Determine friend id by peer TLS certificate try { SSLSocket sslSocket = (SSLSocket)socket; SSLSession sslSession = sslSocket.getSession(); Certificate[] certificates = sslSession.getPeerCertificates(); if (certificates.length != 1) { throw new Utils.ApplicationError(LOG_TAG, "unexpected peer certificate count"); } return Utils.encodeBase64(certificates[0].getEncoded()); } catch (SSLPeerUnverifiedException e) { throw new Utils.ApplicationError(LOG_TAG, e); } catch (CertificateEncodingException e) { throw new Utils.ApplicationError(LOG_TAG, e); } } @Override public Response serve(IHTTPSession session) { String certificate = null; try { certificate = getPeerCertificate(session.getSocket()); } catch (Utils.ApplicationError e) { Log.addEntry(LOG_TAG, "failed to get peer certificate"); return new Response(NanoHTTPD.Response.Status.FORBIDDEN, null, ""); } try { String uri = session.getUri(); Method method = session.getMethod(); if (Method.GET.equals(method) && uri.equals(Protocol.PULL_STATUS_REQUEST_PATH)) { Data.Status status = mRequestHandler.handlePullStatusRequest(certificate); if (status == null) { // TODO: not currently sharing; serve old status? return new Response(NanoHTTPD.Response.Status.FORBIDDEN, null, ""); } return new Response(NanoHTTPD.Response.Status.OK, Protocol.PULL_STATUS_RESPONSE_MIME_TYPE, Json.toJson(status)); } else if (Method.GET.equals(method) && uri.equals(Protocol.DOWNLOAD_REQUEST_PATH)) { String resourceId = session.getParms().get(Protocol.DOWNLOAD_REQUEST_RESOURCE_ID_PARAMETER); if (resourceId == null) { throw new Utils.ApplicationError(LOG_TAG, "download request missing resource id parameter"); } Pair<Long, Long> range = readRangeHeaderHelper(session); RequestHandler.DownloadResponse downloadResponse = mRequestHandler.handleDownloadRequest(certificate, resourceId, range); Response response; if (downloadResponse.mAvailable) { response = new Response(NanoHTTPD.Response.Status.OK, downloadResponse.mMimeType, downloadResponse.mData); response.setChunkedTransfer(true); } else { response = new Response(NanoHTTPD.Response.Status.SERVICE_UNAVAILABLE, null, ""); } return response; } else if (Method.POST.equals(method) && uri.equals(Protocol.PUSH_STATUS_REQUEST_PATH)) { // TODO: PUT more RESTful? Data.Status status = Json.fromJson(new String(readRequestBodyHelper(session)), Data.Status.class); mRequestHandler.handlePushStatusRequest(certificate, status); return new Response(NanoHTTPD.Response.Status.OK, null, ""); } } catch (IOException e) { Log.addEntry(LOG_TAG, e.getMessage()); } catch (Utils.ApplicationError e) { } try { Data.Friend friend = Data.getInstance().getFriendByCertificate(certificate); Log.addEntry(LOG_TAG, "failed to serve request: " + friend.mPublicIdentity.mNickname); } catch (Utils.ApplicationError e) { Log.addEntry(LOG_TAG, "failed to serve request: unrecognized certificate " + certificate.substring(0, 20) + "..."); } return new Response(NanoHTTPD.Response.Status.FORBIDDEN, null, ""); } private Pair<Long, Long> readRangeHeaderHelper(IHTTPSession session) throws Utils.ApplicationError { // From NanoHTTP: https://github.com/NanoHttpd/nanohttpd/blob/master/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java long startFrom = 0; long endAt = -1; String range = session.getHeaders().get("range"); if (range != null && range.startsWith("bytes=")) { range = range.substring("bytes=".length()); int minus = range.indexOf('-'); try { if (minus > 0) { startFrom = Long.parseLong(range.substring(0, minus)); if (minus < range.length() - 1) { endAt = Long.parseLong(range.substring(minus + 1)); } } } catch (NumberFormatException ignored) { throw new Utils.ApplicationError(LOG_TAG, "invalid range header"); } } return new Pair<Long, Long>(startFrom, endAt); } private byte[] readRequestBodyHelper(IHTTPSession session) throws IOException, Utils.ApplicationError { String contentLengthValue = session.getHeaders().get("content-length"); if (contentLengthValue == null) { throw new Utils.ApplicationError(LOG_TAG, "failed to get request content length"); } int contentLength = 0; try { contentLength = Integer.parseInt(contentLengthValue); } catch (NumberFormatException e) { throw new Utils.ApplicationError(LOG_TAG, "invalid request content length"); } if (contentLength > Protocol.MAX_POST_REQUEST_BODY_SIZE) { throw new Utils.ApplicationError(LOG_TAG, "content length too large: " + Integer.toString(contentLength)); } byte[] buffer = new byte[contentLength]; int offset = 0; int remainingLength = contentLength; while (remainingLength > 0) { int readLength = session.getInputStream().read(buffer, offset, remainingLength); if (readLength == -1 || readLength > remainingLength) { throw new Utils.ApplicationError(LOG_TAG, String.format( "failed to read POST content: read %d of %d expected bytes", contentLength - remainingLength, contentLength)); } offset += readLength; remainingLength -= readLength; } return buffer; } }