1#!/usr/bin/env vpython3 2# Copyright 2013 The Chromium Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""This is a simple HTTP/TCP/PROXY/BASIC_AUTH_PROXY/WEBSOCKET server used for 7testing Chrome. 8 9It supports several test URLs, as specified by the handlers in TestPageHandler. 10By default, it listens on an ephemeral port and sends the port number back to 11the originating process over a pipe. The originating process can specify an 12explicit port if necessary. 13""" 14 15from __future__ import print_function 16 17import base64 18import logging 19import os 20import select 21from six.moves import BaseHTTPServer, socketserver 22import six.moves.urllib.parse as urlparse 23import socket 24import ssl 25import sys 26 27BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 28ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(BASE_DIR))) 29 30# Insert at the beginning of the path, we want to use our copies of the library 31# unconditionally (since they contain modifications from anything that might be 32# obtained from e.g. PyPi). 33sys.path.insert(0, os.path.join(ROOT_DIR, 'third_party', 'pywebsocket3', 'src')) 34 35import mod_pywebsocket.standalone 36from mod_pywebsocket.standalone import WebSocketServer 37# import manually 38mod_pywebsocket.standalone.ssl = ssl 39 40import testserver_base 41 42SERVER_UNSET = 0 43SERVER_BASIC_AUTH_PROXY = 1 44SERVER_WEBSOCKET = 2 45SERVER_PROXY = 3 46 47# Default request queue size for WebSocketServer. 48_DEFAULT_REQUEST_QUEUE_SIZE = 128 49 50class WebSocketOptions: 51 """Holds options for WebSocketServer.""" 52 53 def __init__(self, host, port, data_dir): 54 self.request_queue_size = _DEFAULT_REQUEST_QUEUE_SIZE 55 self.server_host = host 56 self.port = port 57 self.websock_handlers = data_dir 58 self.scan_dir = None 59 self.allow_handlers_outside_root_dir = False 60 self.websock_handlers_map_file = None 61 self.cgi_directories = [] 62 self.is_executable_method = None 63 64 self.use_tls = False 65 self.private_key = None 66 self.certificate = None 67 self.tls_client_auth = False 68 self.tls_client_ca = None 69 self.use_basic_auth = False 70 self.basic_auth_credential = 'Basic ' + base64.b64encode( 71 b'test:test').decode() 72 73 74class ThreadingHTTPServer(socketserver.ThreadingMixIn, 75 testserver_base.ClientRestrictingServerMixIn, 76 testserver_base.BrokenPipeHandlerMixIn, 77 testserver_base.StoppableHTTPServer): 78 """This is a specialization of StoppableHTTPServer that adds client 79 verification and creates a new thread for every connection. It 80 should only be used with handlers that are known to be threadsafe.""" 81 82 pass 83 84 85class TestPageHandler(testserver_base.BasePageHandler): 86 def __init__(self, request, client_address, socket_server): 87 connect_handlers = [self.DefaultConnectResponseHandler] 88 get_handlers = [self.DefaultResponseHandler] 89 post_handlers = get_handlers 90 put_handlers = get_handlers 91 head_handlers = [self.DefaultResponseHandler] 92 testserver_base.BasePageHandler.__init__(self, request, client_address, 93 socket_server, connect_handlers, 94 get_handlers, head_handlers, 95 post_handlers, put_handlers) 96 97 def DefaultResponseHandler(self): 98 """This is the catch-all response handler for requests that aren't handled 99 by one of the special handlers above. 100 Note that we specify the content-length as without it the https connection 101 is not closed properly (and the browser keeps expecting data).""" 102 103 contents = "Default response given for path: " + self.path 104 self.send_response(200) 105 self.send_header('Content-Type', 'text/html') 106 self.send_header('Content-Length', len(contents)) 107 self.end_headers() 108 if (self.command != 'HEAD'): 109 self.wfile.write(contents.encode('utf8')) 110 return True 111 112 def DefaultConnectResponseHandler(self): 113 """This is the catch-all response handler for CONNECT requests that aren't 114 handled by one of the special handlers above. Real Web servers respond 115 with 400 to CONNECT requests.""" 116 117 contents = "Your client has issued a malformed or illegal request." 118 self.send_response(400) # bad request 119 self.send_header('Content-Type', 'text/html') 120 self.send_header('Content-Length', len(contents)) 121 self.end_headers() 122 self.wfile.write(contents.encode('utf8')) 123 return True 124 125 126class ProxyRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): 127 """A request handler that behaves as a proxy server. Only CONNECT, GET and 128 HEAD methods are supported. 129 """ 130 131 redirect_connect_to_localhost = False; 132 133 def _start_read_write(self, sock): 134 sock.setblocking(0) 135 self.request.setblocking(0) 136 rlist = [self.request, sock] 137 while True: 138 ready_sockets, _unused, errors = select.select(rlist, [], []) 139 if errors: 140 self.send_response(500) 141 self.end_headers() 142 return 143 for s in ready_sockets: 144 received = s.recv(1024) 145 if len(received) == 0: 146 return 147 if s == self.request: 148 other = sock 149 else: 150 other = self.request 151 # This will lose data if the kernel write buffer fills up. 152 # TODO(ricea): Correctly use the return value to track how much was 153 # written and buffer the rest. Use select to determine when the socket 154 # becomes writable again. 155 other.send(received) 156 157 def _do_common_method(self): 158 url = urlparse.urlparse(self.path) 159 port = url.port 160 if not port: 161 if url.scheme == 'http': 162 port = 80 163 elif url.scheme == 'https': 164 port = 443 165 if not url.hostname or not port: 166 self.send_response(400) 167 self.end_headers() 168 return 169 170 if len(url.path) == 0: 171 path = '/' 172 else: 173 path = url.path 174 if len(url.query) > 0: 175 path = '%s?%s' % (url.path, url.query) 176 177 sock = None 178 try: 179 sock = socket.create_connection((url.hostname, port)) 180 sock.send(('%s %s %s\r\n' % 181 (self.command, path, self.protocol_version)).encode('utf-8')) 182 for name, value in self.headers.items(): 183 if (name.lower().startswith('connection') 184 or name.lower().startswith('proxy')): 185 continue 186 # HTTP headers are encoded in Latin-1. 187 sock.send(b'%s: %s\r\n' % 188 (name.encode('latin-1'), value.encode('latin-1'))) 189 sock.send(b'\r\n') 190 # This is wrong: it will pass through connection-level headers and 191 # misbehave on connection reuse. The only reason it works at all is that 192 # our test servers have never supported connection reuse. 193 # TODO(ricea): Use a proper HTTP client library instead. 194 self._start_read_write(sock) 195 except Exception: 196 logging.exception('failure in common method: %s %s', self.command, path) 197 self.send_response(500) 198 self.end_headers() 199 finally: 200 if sock is not None: 201 sock.close() 202 203 def do_CONNECT(self): 204 try: 205 pos = self.path.rfind(':') 206 host = self.path[:pos] 207 port = int(self.path[pos+1:]) 208 except Exception: 209 self.send_response(400) 210 self.end_headers() 211 212 if ProxyRequestHandler.redirect_connect_to_localhost: 213 host = "127.0.0.1" 214 215 sock = None 216 try: 217 sock = socket.create_connection((host, port)) 218 self.send_response(200, 'Connection established') 219 self.end_headers() 220 self._start_read_write(sock) 221 except Exception: 222 logging.exception('failure in CONNECT: %s', path) 223 self.send_response(500) 224 self.end_headers() 225 finally: 226 if sock is not None: 227 sock.close() 228 229 def do_GET(self): 230 self._do_common_method() 231 232 def do_HEAD(self): 233 self._do_common_method() 234 235class BasicAuthProxyRequestHandler(ProxyRequestHandler): 236 """A request handler that behaves as a proxy server which requires 237 basic authentication. 238 """ 239 240 _AUTH_CREDENTIAL = 'Basic Zm9vOmJhcg==' # foo:bar 241 242 def parse_request(self): 243 """Overrides parse_request to check credential.""" 244 245 if not ProxyRequestHandler.parse_request(self): 246 return False 247 248 auth = self.headers.get('Proxy-Authorization', None) 249 if auth != self._AUTH_CREDENTIAL: 250 self.send_response(407) 251 self.send_header('Proxy-Authenticate', 'Basic realm="MyRealm1"') 252 self.end_headers() 253 return False 254 255 return True 256 257 258class ServerRunner(testserver_base.TestServerRunner): 259 """TestServerRunner for the net test servers.""" 260 261 def __init__(self): 262 super(ServerRunner, self).__init__() 263 264 def __make_data_dir(self): 265 if self.options.data_dir: 266 if not os.path.isdir(self.options.data_dir): 267 raise testserver_base.OptionError('specified data dir not found: ' + 268 self.options.data_dir + ' exiting...') 269 my_data_dir = self.options.data_dir 270 else: 271 # Create the default path to our data dir, relative to the exe dir. 272 my_data_dir = os.path.join(BASE_DIR, "..", "..", "data") 273 274 return my_data_dir 275 276 def create_server(self, server_data): 277 port = self.options.port 278 host = self.options.host 279 280 logging.basicConfig() 281 282 # Work around a bug in Mac OS 10.6. Spawning a WebSockets server 283 # will result in a call to |getaddrinfo|, which fails with "nodename 284 # nor servname provided" for localhost:0 on 10.6. 285 # TODO(ricea): Remove this if no longer needed. 286 if self.options.server_type == SERVER_WEBSOCKET and \ 287 host == "localhost" and \ 288 port == 0: 289 host = "127.0.0.1" 290 291 # Construct the subjectAltNames for any ad-hoc generated certificates. 292 # As host can be either a DNS name or IP address, attempt to determine 293 # which it is, so it can be placed in the appropriate SAN. 294 dns_sans = None 295 ip_sans = None 296 ip = None 297 try: 298 ip = socket.inet_aton(host) 299 ip_sans = [ip] 300 except socket.error: 301 pass 302 if ip is None: 303 dns_sans = [host] 304 305 if self.options.server_type == SERVER_UNSET: 306 raise testserver_base.OptionError('no server type specified') 307 elif self.options.server_type == SERVER_WEBSOCKET: 308 # TODO(toyoshim): Remove following os.chdir. Currently this operation 309 # is required to work correctly. It should be fixed from pywebsocket side. 310 os.chdir(self.__make_data_dir()) 311 websocket_options = WebSocketOptions(host, port, '.') 312 scheme = "ws" 313 if self.options.cert_and_key_file: 314 scheme = "wss" 315 websocket_options.use_tls = True 316 key_path = os.path.join(ROOT_DIR, self.options.cert_and_key_file) 317 if not os.path.isfile(key_path): 318 raise testserver_base.OptionError( 319 'specified server cert file not found: ' + 320 self.options.cert_and_key_file + ' exiting...') 321 websocket_options.private_key = key_path 322 websocket_options.certificate = key_path 323 324 if self.options.ssl_client_auth: 325 websocket_options.tls_client_cert_optional = False 326 websocket_options.tls_client_auth = True 327 if len(self.options.ssl_client_ca) != 1: 328 raise testserver_base.OptionError( 329 'one trusted client CA file should be specified') 330 if not os.path.isfile(self.options.ssl_client_ca[0]): 331 raise testserver_base.OptionError( 332 'specified trusted client CA file not found: ' + 333 self.options.ssl_client_ca[0] + ' exiting...') 334 websocket_options.tls_client_ca = self.options.ssl_client_ca[0] 335 print('Trying to start websocket server on %s://%s:%d...' % 336 (scheme, websocket_options.server_host, websocket_options.port)) 337 server = WebSocketServer(websocket_options) 338 print('WebSocket server started on %s://%s:%d...' % 339 (scheme, host, server.server_port)) 340 server_data['port'] = server.server_port 341 websocket_options.use_basic_auth = self.options.ws_basic_auth 342 elif self.options.server_type == SERVER_PROXY: 343 ProxyRequestHandler.redirect_connect_to_localhost = \ 344 self.options.redirect_connect_to_localhost 345 server = ThreadingHTTPServer((host, port), ProxyRequestHandler) 346 print('Proxy server started on port %d...' % server.server_port) 347 server_data['port'] = server.server_port 348 elif self.options.server_type == SERVER_BASIC_AUTH_PROXY: 349 ProxyRequestHandler.redirect_connect_to_localhost = \ 350 self.options.redirect_connect_to_localhost 351 server = ThreadingHTTPServer((host, port), BasicAuthProxyRequestHandler) 352 print('BasicAuthProxy server started on port %d...' % server.server_port) 353 server_data['port'] = server.server_port 354 else: 355 raise testserver_base.OptionError('unknown server type' + 356 self.options.server_type) 357 358 return server 359 360 def add_options(self): 361 testserver_base.TestServerRunner.add_options(self) 362 self.option_parser.add_option('--proxy', 363 action='store_const', 364 const=SERVER_PROXY, 365 default=SERVER_UNSET, 366 dest='server_type', 367 help='start up a proxy server.') 368 self.option_parser.add_option('--basic-auth-proxy', 369 action='store_const', 370 const=SERVER_BASIC_AUTH_PROXY, 371 default=SERVER_UNSET, 372 dest='server_type', 373 help='start up a proxy server which requires ' 374 'basic authentication.') 375 self.option_parser.add_option('--websocket', 376 action='store_const', 377 const=SERVER_WEBSOCKET, 378 default=SERVER_UNSET, 379 dest='server_type', 380 help='start up a WebSocket server.') 381 self.option_parser.add_option('--cert-and-key-file', 382 dest='cert_and_key_file', help='specify the ' 383 'path to the file containing the certificate ' 384 'and private key for the server in PEM ' 385 'format') 386 self.option_parser.add_option('--ssl-client-auth', action='store_true', 387 help='Require SSL client auth on every ' 388 'connection.') 389 self.option_parser.add_option('--ssl-client-ca', action='append', 390 default=[], help='Specify that the client ' 391 'certificate request should include the CA ' 392 'named in the subject of the DER-encoded ' 393 'certificate contained in the specified ' 394 'file. This option may appear multiple ' 395 'times, indicating multiple CA names should ' 396 'be sent in the request.') 397 self.option_parser.add_option('--file-root-url', default='/files/', 398 help='Specify a root URL for files served.') 399 # TODO(ricea): Generalize this to support basic auth for HTTP too. 400 self.option_parser.add_option('--ws-basic-auth', action='store_true', 401 dest='ws_basic_auth', 402 help='Enable basic-auth for WebSocket') 403 self.option_parser.add_option('--redirect-connect-to-localhost', 404 dest='redirect_connect_to_localhost', 405 default=False, action='store_true', 406 help='If set, the Proxy server will connect ' 407 'to localhost instead of the requested URL ' 408 'on CONNECT requests') 409 410 411if __name__ == '__main__': 412 sys.exit(ServerRunner().main()) 413