xref: /aosp_15_r20/external/cronet/net/tools/testserver/testserver.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
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