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