TestWebServer.java revision 2a99a7e74a7f215066514fe81d2bfa6639d9eddd
1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.net.test.util;
6
7import android.util.Base64;
8import android.util.Log;
9import android.util.Pair;
10
11import org.apache.http.HttpException;
12import org.apache.http.HttpRequest;
13import org.apache.http.HttpResponse;
14import org.apache.http.HttpStatus;
15import org.apache.http.HttpVersion;
16import org.apache.http.RequestLine;
17import org.apache.http.StatusLine;
18import org.apache.http.entity.ByteArrayEntity;
19import org.apache.http.impl.DefaultHttpServerConnection;
20import org.apache.http.impl.cookie.DateUtils;
21import org.apache.http.message.BasicHttpResponse;
22import org.apache.http.params.BasicHttpParams;
23import org.apache.http.params.CoreProtocolPNames;
24import org.apache.http.params.HttpParams;
25
26import java.io.ByteArrayInputStream;
27import java.io.IOException;
28import java.io.InputStream;
29import java.net.MalformedURLException;
30import java.net.ServerSocket;
31import java.net.Socket;
32import java.net.URI;
33import java.net.URL;
34import java.net.URLConnection;
35import java.security.KeyManagementException;
36import java.security.KeyStore;
37import java.security.NoSuchAlgorithmException;
38import java.security.cert.X509Certificate;
39import java.util.ArrayList;
40import java.util.Date;
41import java.util.HashMap;
42import java.util.Hashtable;
43import java.util.List;
44import java.util.Map;
45
46import javax.net.ssl.HostnameVerifier;
47import javax.net.ssl.HttpsURLConnection;
48import javax.net.ssl.KeyManager;
49import javax.net.ssl.KeyManagerFactory;
50import javax.net.ssl.SSLContext;
51import javax.net.ssl.SSLSession;
52import javax.net.ssl.X509TrustManager;
53
54/**
55 * Simple http test server for testing.
56 *
57 * This server runs in a thread in the current process, so it is convenient
58 * for loopback testing without the need to setup tcp forwarding to the
59 * host computer.
60 *
61 * Based heavily on the CTSWebServer in Android.
62 */
63public class TestWebServer {
64    private static final String TAG = "TestWebServer";
65    private static final int SERVER_PORT = 4444;
66    private static final int SSL_SERVER_PORT = 4445;
67
68    public static final String SHUTDOWN_PREFIX = "/shutdown";
69
70    private static TestWebServer sInstance;
71    private static Hashtable<Integer, String> sReasons;
72
73    private final ServerThread mServerThread;
74    private String mServerUri;
75    private final boolean mSsl;
76
77    private static class Response {
78        final byte[] mResponseData;
79        final List<Pair<String, String>> mResponseHeaders;
80        final boolean mIsRedirect;
81
82        Response(byte[] resposneData, List<Pair<String, String>> responseHeaders,
83                boolean isRedirect) {
84            mIsRedirect = isRedirect;
85            mResponseData = resposneData;
86            mResponseHeaders = responseHeaders == null ?
87                    new ArrayList<Pair<String, String>>() : responseHeaders;
88        }
89    }
90
91    // The Maps below are modified on both the client thread and the internal server thread, so
92    // need to use a lock when accessing them.
93    private final Object mLock = new Object();
94    private final Map<String, Response> mResponseMap = new HashMap<String, Response>();
95    private final Map<String, Integer> mResponseCountMap = new HashMap<String, Integer>();
96    private final Map<String, HttpRequest> mLastRequestMap = new HashMap<String, HttpRequest>();
97
98    /**
99     * Create and start a local HTTP server instance.
100     * @param ssl True if the server should be using secure sockets.
101     * @throws Exception
102     */
103    public TestWebServer(boolean ssl) throws Exception {
104        if (sInstance != null) {
105            // attempt to start a new instance while one is still running
106            // shut down the old instance first
107            sInstance.shutdown();
108        }
109        setStaticInstance(this);
110        mSsl = ssl;
111        if (mSsl) {
112            mServerUri = "https://localhost:" + SSL_SERVER_PORT;
113        } else {
114            mServerUri = "http://localhost:" + SERVER_PORT;
115        }
116        mServerThread = new ServerThread(this, mSsl);
117        mServerThread.start();
118    }
119
120    private static void setStaticInstance(TestWebServer instance) {
121        sInstance = instance;
122    }
123
124    /**
125     * Terminate the http server.
126     */
127    public void shutdown() {
128        try {
129            // Avoid a deadlock between two threads where one is trying to call
130            // close() and the other one is calling accept() by sending a GET
131            // request for shutdown and having the server's one thread
132            // sequentially call accept() and close().
133            URL url = new URL(mServerUri + SHUTDOWN_PREFIX);
134            URLConnection connection = openConnection(url);
135            connection.connect();
136
137            // Read the input from the stream to send the request.
138            InputStream is = connection.getInputStream();
139            is.close();
140
141            // Block until the server thread is done shutting down.
142            mServerThread.join();
143
144        } catch (MalformedURLException e) {
145            throw new IllegalStateException(e);
146        } catch (InterruptedException e) {
147            throw new RuntimeException(e);
148        } catch (IOException e) {
149            throw new RuntimeException(e);
150        } catch (NoSuchAlgorithmException e) {
151            throw new IllegalStateException(e);
152        } catch (KeyManagementException e) {
153            throw new IllegalStateException(e);
154        }
155
156        setStaticInstance(null);
157    }
158
159    private final static int RESPONSE_STATUS_NORMAL = 0;
160    private final static int RESPONSE_STATUS_MOVED_TEMPORARILY = 1;
161
162    private String setResponseInternal(
163            String requestPath, byte[] responseData,
164            List<Pair<String, String>> responseHeaders,
165            int status) {
166        final boolean isRedirect = (status == RESPONSE_STATUS_MOVED_TEMPORARILY);
167
168        synchronized (mLock) {
169            mResponseMap.put(requestPath, new Response(responseData, responseHeaders, isRedirect));
170            mResponseCountMap.put(requestPath, Integer.valueOf(0));
171            mLastRequestMap.put(requestPath, null);
172        }
173        return getResponseUrl(requestPath);
174    }
175
176    /**
177     * Gets the URL on the server under which a particular request path will be accessible.
178     *
179     * This only gets the URL, you still need to set the response if you intend to access it.
180     *
181     * @param requestPath The path to respond to.
182     * @return The full URL including the requestPath.
183     */
184    public String getResponseUrl(String requestPath) {
185        return mServerUri + requestPath;
186    }
187
188    /**
189     * Sets a response to be returned when a particular request path is passed
190     * in (with the option to specify additional headers).
191     *
192     * @param requestPath The path to respond to.
193     * @param responseString The response body that will be returned.
194     * @param responseHeaders Any additional headers that should be returned along with the
195     *                        response (null is acceptable).
196     * @return The full URL including the path that should be requested to get the expected
197     *         response.
198     */
199    public String setResponse(
200            String requestPath, String responseString,
201            List<Pair<String, String>> responseHeaders) {
202        return setResponseInternal(requestPath, responseString.getBytes(), responseHeaders,
203                RESPONSE_STATUS_NORMAL);
204    }
205
206    /**
207     * Sets a redirect.
208     *
209     * @param requestPath The path to respond to.
210     * @param targetPath The path to redirect to.
211     * @return The full URL including the path that should be requested to get the expected
212     *         response.
213     */
214    public String setRedirect(
215            String requestPath, String targetPath) {
216        List<Pair<String, String>> responseHeaders = new ArrayList<Pair<String, String>>();
217        responseHeaders.add(Pair.create("Location", targetPath));
218
219        return setResponseInternal(requestPath, targetPath.getBytes(), responseHeaders,
220                RESPONSE_STATUS_MOVED_TEMPORARILY);
221    }
222
223    /**
224     * Sets a base64 encoded response to be returned when a particular request path is passed
225     * in (with the option to specify additional headers).
226     *
227     * @param requestPath The path to respond to.
228     * @param base64EncodedResponse The response body that is base64 encoded. The actual server
229     *                              response will the decoded binary form.
230     * @param responseHeaders Any additional headers that should be returned along with the
231     *                        response (null is acceptable).
232     * @return The full URL including the path that should be requested to get the expected
233     *         response.
234     */
235    public String setResponseBase64(
236            String requestPath, String base64EncodedResponse,
237            List<Pair<String, String>> responseHeaders) {
238        return setResponseInternal(requestPath,
239                                   Base64.decode(base64EncodedResponse, Base64.DEFAULT),
240                                   responseHeaders,
241                                   RESPONSE_STATUS_NORMAL);
242    }
243
244    /**
245     * Get the number of requests was made at this path since it was last set.
246     */
247    public int getRequestCount(String requestPath) {
248        Integer count = null;
249        synchronized (mLock) {
250            count = mResponseCountMap.get(requestPath);
251        }
252        if (count == null) throw new IllegalArgumentException("Path not set: " + requestPath);
253        return count.intValue();
254    }
255
256    /**
257     * Returns the last HttpRequest at this path. Can return null if it is never requested.
258     */
259    public HttpRequest getLastRequest(String requestPath) {
260        synchronized (mLock) {
261            if (!mLastRequestMap.containsKey(requestPath))
262                throw new IllegalArgumentException("Path not set: " + requestPath);
263            return mLastRequestMap.get(requestPath);
264        }
265    }
266
267    public String getBaseUrl() {
268        return mServerUri + "/";
269    }
270
271    private URLConnection openConnection(URL url)
272            throws IOException, NoSuchAlgorithmException, KeyManagementException {
273        if (mSsl) {
274            // Install hostname verifiers and trust managers that don't do
275            // anything in order to get around the client not trusting
276            // the test server due to a lack of certificates.
277
278            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
279            connection.setHostnameVerifier(new TestHostnameVerifier());
280
281            SSLContext context = SSLContext.getInstance("TLS");
282            TestTrustManager trustManager = new TestTrustManager();
283            context.init(null, new TestTrustManager[] {trustManager}, null);
284            connection.setSSLSocketFactory(context.getSocketFactory());
285
286            return connection;
287        } else {
288            return url.openConnection();
289        }
290    }
291
292    /**
293     * {@link X509TrustManager} that trusts everybody. This is used so that
294     * the client calling {@link TestWebServer#shutdown()} can issue a request
295     * for shutdown by blindly trusting the {@link TestWebServer}'s
296     * credentials.
297     */
298    private static class TestTrustManager implements X509TrustManager {
299        @Override
300        public void checkClientTrusted(X509Certificate[] chain, String authType) {
301            // Trust the TestWebServer...
302        }
303
304        @Override
305        public void checkServerTrusted(X509Certificate[] chain, String authType) {
306            // Trust the TestWebServer...
307        }
308
309        @Override
310        public X509Certificate[] getAcceptedIssuers() {
311            return null;
312        }
313    }
314
315    /**
316     * {@link HostnameVerifier} that verifies everybody. This permits
317     * the client to trust the web server and call
318     * {@link TestWebServer#shutdown()}.
319     */
320    private static class TestHostnameVerifier implements HostnameVerifier {
321        @Override
322        public boolean verify(String hostname, SSLSession session) {
323            return true;
324        }
325    }
326
327    private void servedResponseFor(String path, HttpRequest request) {
328        synchronized (mLock) {
329            mResponseCountMap.put(path, Integer.valueOf(
330                    mResponseCountMap.get(path).intValue() + 1));
331            mLastRequestMap.put(path, request);
332        }
333    }
334
335    /**
336     * Generate a response to the given request.
337     * @throws InterruptedException
338     */
339    private HttpResponse getResponse(HttpRequest request) throws InterruptedException {
340        RequestLine requestLine = request.getRequestLine();
341        HttpResponse httpResponse = null;
342        Log.i(TAG, requestLine.getMethod() + ": " + requestLine.getUri());
343        String uriString = requestLine.getUri();
344        URI uri = URI.create(uriString);
345        String path = uri.getPath();
346
347        Response response = null;
348        synchronized (mLock) {
349          response = mResponseMap.get(path);
350        }
351        if (path.equals(SHUTDOWN_PREFIX)) {
352            httpResponse = createResponse(HttpStatus.SC_OK);
353        } else if (response == null) {
354            httpResponse = createResponse(HttpStatus.SC_NOT_FOUND);
355        } else if (response.mIsRedirect) {
356            httpResponse = createResponse(HttpStatus.SC_MOVED_TEMPORARILY);
357            for (Pair<String, String> header : response.mResponseHeaders) {
358                httpResponse.addHeader(header.first, header.second);
359            }
360            servedResponseFor(path, request);
361        } else {
362            httpResponse = createResponse(HttpStatus.SC_OK);
363            httpResponse.setEntity(createEntity(response.mResponseData));
364            for (Pair<String, String> header : response.mResponseHeaders) {
365                httpResponse.addHeader(header.first, header.second);
366            }
367            servedResponseFor(path, request);
368        }
369        StatusLine sl = httpResponse.getStatusLine();
370        Log.i(TAG, sl.getStatusCode() + "(" + sl.getReasonPhrase() + ")");
371        setDateHeaders(httpResponse);
372        return httpResponse;
373    }
374
375    private void setDateHeaders(HttpResponse response) {
376        response.addHeader("Date", DateUtils.formatDate(new Date(), DateUtils.PATTERN_RFC1123));
377    }
378
379    /**
380     * Create an empty response with the given status.
381     */
382    private HttpResponse createResponse(int status) {
383        HttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_0, status, null);
384        String reason = null;
385
386        // This synchronized silences findbugs.
387        synchronized (TestWebServer.class) {
388            if (sReasons == null) {
389                sReasons = new Hashtable<Integer, String>();
390                sReasons.put(HttpStatus.SC_UNAUTHORIZED, "Unauthorized");
391                sReasons.put(HttpStatus.SC_NOT_FOUND, "Not Found");
392                sReasons.put(HttpStatus.SC_FORBIDDEN, "Forbidden");
393                sReasons.put(HttpStatus.SC_MOVED_TEMPORARILY, "Moved Temporarily");
394            }
395            // Fill in error reason. Avoid use of the ReasonPhraseCatalog, which is
396            // Locale-dependent.
397            reason = sReasons.get(status);
398        }
399
400        if (reason != null) {
401            StringBuffer buf = new StringBuffer("<html><head><title>");
402            buf.append(reason);
403            buf.append("</title></head><body>");
404            buf.append(reason);
405            buf.append("</body></html>");
406            response.setEntity(createEntity(buf.toString().getBytes()));
407        }
408        return response;
409    }
410
411    /**
412     * Create a string entity for the given content.
413     */
414    private ByteArrayEntity createEntity(byte[] data) {
415        ByteArrayEntity entity = new ByteArrayEntity(data);
416        entity.setContentType("text/html");
417        return entity;
418    }
419
420    private static class ServerThread extends Thread {
421        private TestWebServer mServer;
422        private ServerSocket mSocket;
423        private boolean mIsSsl;
424        private boolean mIsCancelled;
425        private SSLContext mSslContext;
426
427        /**
428         * Defines the keystore contents for the server, BKS version. Holds just a
429         * single self-generated key. The subject name is "Test Server".
430         */
431        private static final String SERVER_KEYS_BKS =
432            "AAAAAQAAABQDkebzoP1XwqyWKRCJEpn/t8dqIQAABDkEAAVteWtleQAAARpYl20nAAAAAQAFWC41" +
433            "MDkAAAJNMIICSTCCAbKgAwIBAgIESEfU1jANBgkqhkiG9w0BAQUFADBpMQswCQYDVQQGEwJVUzET" +
434            "MBEGA1UECBMKQ2FsaWZvcm5pYTEMMAoGA1UEBxMDTVRWMQ8wDQYDVQQKEwZHb29nbGUxEDAOBgNV" +
435            "BAsTB0FuZHJvaWQxFDASBgNVBAMTC1Rlc3QgU2VydmVyMB4XDTA4MDYwNTExNTgxNFoXDTA4MDkw" +
436            "MzExNTgxNFowaTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExDDAKBgNVBAcTA01U" +
437            "VjEPMA0GA1UEChMGR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRQwEgYDVQQDEwtUZXN0IFNlcnZl" +
438            "cjCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0LIdKaIr9/vsTq8BZlA3R+NFWRaH4lGsTAQy" +
439            "DPMF9ZqEDOaL6DJuu0colSBBBQ85hQTPa9m9nyJoN3pEi1hgamqOvQIWcXBk+SOpUGRZZFXwniJV" +
440            "zDKU5nE9MYgn2B9AoiH3CSuMz6HRqgVaqtppIe1jhukMc/kHVJvlKRNy9XMCAwEAATANBgkqhkiG" +
441            "9w0BAQUFAAOBgQC7yBmJ9O/eWDGtSH9BH0R3dh2NdST3W9hNZ8hIa8U8klhNHbUCSSktZmZkvbPU" +
442            "hse5LI3dh6RyNDuqDrbYwcqzKbFJaq/jX9kCoeb3vgbQElMRX8D2ID1vRjxwlALFISrtaN4VpWzV" +
443            "yeoHPW4xldeZmoVtjn8zXNzQhLuBqX2MmAAAAqwAAAAUvkUScfw9yCSmALruURNmtBai7kQAAAZx" +
444            "4Jmijxs/l8EBaleaUru6EOPioWkUAEVWCxjM/TxbGHOi2VMsQWqRr/DZ3wsDmtQgw3QTrUK666sR" +
445            "MBnbqdnyCyvM1J2V1xxLXPUeRBmR2CXorYGF9Dye7NkgVdfA+9g9L/0Au6Ugn+2Cj5leoIgkgApN" +
446            "vuEcZegFlNOUPVEs3SlBgUF1BY6OBM0UBHTPwGGxFBBcetcuMRbUnu65vyDG0pslT59qpaR0TMVs" +
447            "P+tcheEzhyjbfM32/vwhnL9dBEgM8qMt0sqF6itNOQU/F4WGkK2Cm2v4CYEyKYw325fEhzTXosck" +
448            "MhbqmcyLab8EPceWF3dweoUT76+jEZx8lV2dapR+CmczQI43tV9btsd1xiBbBHAKvymm9Ep9bPzM" +
449            "J0MQi+OtURL9Lxke/70/MRueqbPeUlOaGvANTmXQD2OnW7PISwJ9lpeLfTG0LcqkoqkbtLKQLYHI" +
450            "rQfV5j0j+wmvmpMxzjN3uvNajLa4zQ8l0Eok9SFaRr2RL0gN8Q2JegfOL4pUiHPsh64WWya2NB7f" +
451            "V+1s65eA5ospXYsShRjo046QhGTmymwXXzdzuxu8IlnTEont6P4+J+GsWk6cldGbl20hctuUKzyx" +
452            "OptjEPOKejV60iDCYGmHbCWAzQ8h5MILV82IclzNViZmzAapeeCnexhpXhWTs+xDEYSKEiG/camt" +
453            "bhmZc3BcyVJrW23PktSfpBQ6D8ZxoMfF0L7V2GQMaUg+3r7ucrx82kpqotjv0xHghNIm95aBr1Qw" +
454            "1gaEjsC/0wGmmBDg1dTDH+F1p9TInzr3EFuYD0YiQ7YlAHq3cPuyGoLXJ5dXYuSBfhDXJSeddUkl" +
455            "k1ufZyOOcskeInQge7jzaRfmKg3U94r+spMEvb0AzDQVOKvjjo1ivxMSgFRZaDb/4qw=";
456
457        private static final String PASSWORD = "android";
458
459        /**
460         * Loads a keystore from a base64-encoded String. Returns the KeyManager[]
461         * for the result.
462         */
463        private KeyManager[] getKeyManagers() throws Exception {
464            byte[] bytes = Base64.decode(SERVER_KEYS_BKS, Base64.DEFAULT);
465            InputStream inputStream = new ByteArrayInputStream(bytes);
466
467            KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
468            keyStore.load(inputStream, PASSWORD.toCharArray());
469            inputStream.close();
470
471            String algorithm = KeyManagerFactory.getDefaultAlgorithm();
472            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(algorithm);
473            keyManagerFactory.init(keyStore, PASSWORD.toCharArray());
474
475            return keyManagerFactory.getKeyManagers();
476        }
477
478
479        public ServerThread(TestWebServer server, boolean ssl) throws Exception {
480            super("ServerThread");
481            mServer = server;
482            mIsSsl = ssl;
483            int retry = 3;
484            while (true) {
485                try {
486                    if (mIsSsl) {
487                        mSslContext = SSLContext.getInstance("TLS");
488                        mSslContext.init(getKeyManagers(), null, null);
489                        mSocket = mSslContext.getServerSocketFactory().createServerSocket(
490                                SSL_SERVER_PORT);
491                    } else {
492                        mSocket = new ServerSocket(SERVER_PORT);
493                    }
494                    return;
495                } catch (IOException e) {
496                    Log.w(TAG, e);
497                    if (--retry == 0) {
498                        throw e;
499                    }
500                    // sleep in case server socket is still being closed
501                    Thread.sleep(1000);
502                }
503            }
504        }
505
506        @Override
507        public void run() {
508            HttpParams params = new BasicHttpParams();
509            params.setParameter(CoreProtocolPNames.PROTOCOL_VERSION, HttpVersion.HTTP_1_0);
510            while (!mIsCancelled) {
511                try {
512                    Socket socket = mSocket.accept();
513                    DefaultHttpServerConnection conn = new DefaultHttpServerConnection();
514                    conn.bind(socket, params);
515
516                    // Determine whether we need to shutdown early before
517                    // parsing the response since conn.close() will crash
518                    // for SSL requests due to UnsupportedOperationException.
519                    HttpRequest request = conn.receiveRequestHeader();
520                    if (isShutdownRequest(request)) {
521                        mIsCancelled = true;
522                    }
523
524                    HttpResponse response = mServer.getResponse(request);
525                    conn.sendResponseHeader(response);
526                    conn.sendResponseEntity(response);
527                    conn.close();
528
529                } catch (IOException e) {
530                    // normal during shutdown, ignore
531                    Log.w(TAG, e);
532                } catch (HttpException e) {
533                    Log.w(TAG, e);
534                } catch (InterruptedException e) {
535                    Log.w(TAG, e);
536                } catch (UnsupportedOperationException e) {
537                    // DefaultHttpServerConnection's close() throws an
538                    // UnsupportedOperationException.
539                    Log.w(TAG, e);
540                }
541            }
542            try {
543                mSocket.close();
544            } catch (IOException ignored) {
545                // safe to ignore
546            }
547        }
548
549        private boolean isShutdownRequest(HttpRequest request) {
550            RequestLine requestLine = request.getRequestLine();
551            String uriString = requestLine.getUri();
552            URI uri = URI.create(uriString);
553            String path = uri.getPath();
554            return path.equals(SHUTDOWN_PREFIX);
555        }
556    }
557}
558