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