From 5f91b179a3de0eb67057122a023756c07d2fab13 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 10 Oct 2019 15:45:57 -0700 Subject: [PATCH 001/107] Always update latest tag for docker releases --- Makefile | 1 + proxy.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 8cba0b7b7d..5dc56f9406 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,7 @@ run-container: release-container: docker push $(IMAGE_TAG) + docker push $(LATEST_TAG) https-certificates: # Generate server key diff --git a/proxy.py b/proxy.py index 1c924dbb26..dd914b4e36 100755 --- a/proxy.py +++ b/proxy.py @@ -52,8 +52,7 @@ VERSION = (1, 1, 1) __version__ = '.'.join(map(str, VERSION[0:3])) -__description__ = 'Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, ' \ - 'WebSockets protocols in a single Python file.' +__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' __author__ = 'Abhinav Singh' __author_email__ = 'mailsforabhinav@gmail.com' __homepage__ = 'https://github.com/abhinavsingh/proxy.py' From a19da5dcb911fca9a999a222309f31fbc2fcd09f Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 10 Oct 2019 16:18:12 -0700 Subject: [PATCH 002/107] Update issue templates (#123) --- .github/ISSUE_TEMPLATE/bug_report.md | 35 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 +++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..9a7165bb8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: Bug +assignees: abhinavsingh + +--- + +**Check FAQs** +Please check [Frequently Asked Questions](https://github.com/abhinavsingh/proxy.py#frequently-asked-questions) before filling a bug. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Run `proxy.py` as '...' +2. Do '...' to trigger error +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Version information** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Device: [e.g. iPhone6] + - proxy.py Version [e.g. 1.1.1] + +**Additional context** +Add any other context about the problem here. + +**Screenshots** +If applicable, add screenshots to help explain your problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..6b80f3081f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: Enhancement +assignees: abhinavsingh + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From bb7f5a61dc61178008a328688e6c2e4c8defda55 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 10 Oct 2019 22:15:07 -0700 Subject: [PATCH 003/107] Invoke HttpWebServerBasePlugin.handle_request for each request in HTTP/1.1 pipeline (#125) * Add tests to verify certificate generation * Separate out tests for ProtocolHandler and WebServerPlugin * Keep-alive connections for web server. TODO: Only keep-alivei if HTTP/1.1 * Add request.path to avoid build_url repeatedly whose name is also slightly misleading * Fix example usage of request.path * Pipeline only for HTTP/1.1 * Lint fix * Teardown HTTP/1.1 keep-alive request when Connection: close header is sent * Add instructions on how to build docker image locally * Move access_log to separate function for pretty logging --- README.md | 20 ++++- plugin_examples.py | 9 ++- proxy.py | 178 +++++++++++++++++++++++++-------------------- tests.py | 162 ++++++++++++++++++++++++----------------- 4 files changed, 218 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index e7d9e50d86..4bd53a667f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Table of Contents * [Start proxy.py](#start-proxypy) * [Command Line](#command-line) * [Docker Image](#docker-image) + * [Stable version](#stable-version-from-docker-hub) + * [Development version](#build-development-version-locally) * [Plugin Examples](#plugin-examples) * [ProposedRestApiPlugin](#proposedrestapiplugin) * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) @@ -66,7 +68,7 @@ Features ======== - Lightweight - - Distributed as a single file module `~50KB` + - Distributed as a single file module `~100KB` - Uses only `~5-20MB` RAM - No external dependency other than standard Python library - Programmable @@ -104,11 +106,12 @@ or from GitHub `master` branch $ pip install git+https://github.com/abhinavsingh/proxy.py.git@master -or download from here [proxy.py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py) or simply `wget` it: $ wget -q https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py +or download from here [proxy.py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py) + ## Development version $ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop @@ -166,19 +169,28 @@ See [flags](#flags) for full list of available configuration options. ## Docker image +#### Stable Version from Docker Hub + $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest +#### Build Development Version Locally + + $ git clone https://github.com/abhinavsingh/proxy.py.git + $ cd proxy.py + $ make container + $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:v$(./proxy.py -v) + By default `docker` binary is started with IPv4 networking flags: --hostname 0.0.0.0 --port 8899 To override input flags, start docker image as follows. -For example, to check `proxy.py --version`: +For example, to check `proxy.py` version within Docker image: $ docker run -it \ -p 8899:8899 \ --rm abhinavsingh/proxy.py:latest \ - --version + -v [![WARNING](https://img.shields.io/static/v1?label=MacOS&message=warning&color=red)](https://github.com/moby/vpnkit/issues/469) `docker` image is currently broken on `macOS` due to incompatibility with [vpnkit](https://github.com/moby/vpnkit/issues/469). diff --git a/plugin_examples.py b/plugin_examples.py index a9af97ea6e..1751dd7b55 100644 --- a/plugin_examples.py +++ b/plugin_examples.py @@ -108,7 +108,9 @@ def before_upstream_connection(self) -> bool: # Redirect all non-https requests to inbuilt WebServer. if self.request.method != proxy.httpMethods.CONNECT: self.request.url = urlparse.urlsplit(self.UPSTREAM_SERVER) - self.request.set_host_port() + # This command will re-parse modified url and + # update host, port, path fields + self.request.set_line_attributes() return False def on_upstream_connection(self) -> None: @@ -201,10 +203,9 @@ def routes(self) -> List[Tuple[int, bytes]]: ] def handle_request(self, request: proxy.HttpParser) -> None: - path = request.build_url() - if path == b'/http-route-example': + if request.path == b'/http-route-example': self.client.queue(proxy.build_http_response(200, body=b'HTTP route response')) - elif path == b'/https-route-example': + elif request.path == b'/https-route-example': self.client.queue(proxy.build_http_response(200, body=b'HTTPS route response')) def on_websocket_open(self) -> None: diff --git a/proxy.py b/proxy.py index dd914b4e36..0ba62f21e3 100755 --- a/proxy.py +++ b/proxy.py @@ -121,7 +121,7 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: version = bytes_(__version__) -CRLF, COLON, WHITESPACE, COMMA, DOT = b'\r\n', b':', b' ', b',', b'.' +CRLF, COLON, WHITESPACE, COMMA, DOT, HTTP_1_1 = b'\r\n', b':', b' ', b',', b'.', b'HTTP/1.1' PROXY_AGENT_HEADER_KEY = b'Proxy-agent' PROXY_AGENT_HEADER_VALUE = b'proxy.py v' + version PROXY_AGENT_HEADER = PROXY_AGENT_HEADER_KEY + \ @@ -192,7 +192,7 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: def build_http_request(method: bytes, url: bytes, - protocol_version: bytes = b'HTTP/1.1', + protocol_version: bytes = HTTP_1_1, headers: Optional[Dict[bytes, bytes]] = None, body: Optional[bytes] = None) -> bytes: """Build and returns a HTTP request packet.""" @@ -203,7 +203,7 @@ def build_http_request(method: bytes, url: bytes, def build_http_response(status_code: int, - protocol_version: bytes = b'HTTP/1.1', + protocol_version: bytes = HTTP_1_1, reason: Optional[bytes] = None, headers: Optional[Dict[bytes, bytes]] = None, body: Optional[bytes] = None) -> bytes: @@ -537,6 +537,7 @@ def __init__(self, parser_type: int) -> None: # which is broken. self.host: Optional[bytes] = None self.port: Optional[int] = None + self.path: Optional[bytes] = None def header(self, key: bytes) -> bytes: if key.lower() not in self.headers: @@ -561,7 +562,7 @@ def del_headers(self, headers: List[bytes]) -> None: for key in headers: self.del_header(key.lower()) - def set_host_port(self) -> None: + def set_line_attributes(self) -> None: if self.type == httpParserTypes.REQUEST_PARSER: if self.method == httpMethods.CONNECT and self.url: u = urlparse.urlsplit(b'//' + self.url.path) @@ -571,6 +572,7 @@ def set_host_port(self) -> None: if self.url.port else 80 else: raise KeyError('Invalid request\n%s' % self.bytes) + self.path = self.build_url() def is_chunked_encoded(self) -> bool: return b'transfer-encoding' in self.headers and \ @@ -668,7 +670,7 @@ def process_line(self, raw: bytes) -> None: self.version = line[0] self.code = line[1] self.reason = WHITESPACE.join(line[2:]) - self.set_host_port() + self.set_line_attributes() def process_header(self, raw: bytes) -> None: parts = raw.split(COLON) @@ -679,7 +681,6 @@ def process_header(self, raw: bytes) -> None: def build_url(self) -> bytes: if not self.url: return b'/None' - url = self.url.path if url == b'': url = b'/' @@ -690,16 +691,14 @@ def build_url(self) -> bytes: return url def build(self, disable_headers: Optional[List[bytes]] = None) -> bytes: - assert self.method and self.version + assert self.method and self.version and self.path if disable_headers is None: disable_headers = DEFAULT_DISABLE_HEADERS - body: Optional[bytes] = None - if self.is_chunked_encoded() and self.body: - body = ChunkParser.to_chunks(self.body) - else: - body = self.body + body: Optional[bytes] = ChunkParser.to_chunks(self.body) \ + if self.is_chunked_encoded() and self.body else \ + self.body return build_http_request( - self.method, self.build_url(), self.version, + self.method, self.path, self.version, headers={} if not self.headers else {self.headers[k][0]: self.headers[k][1] for k in self.headers if k.lower() not in disable_headers}, body=body @@ -709,6 +708,11 @@ def has_upstream_server(self) -> bool: """Host field SHOULD be None for incoming local WebServer requests.""" return True if self.host is not None else False + def is_http_1_1_keep_alive(self) -> bool: + return self.version == HTTP_1_1 and \ + (not self.has_header(b'Connection') or + self.header(b'Connection').lower() == b'keep-alive') + class AcceptorPool: """AcceptorPool. @@ -864,7 +868,7 @@ def __init__(self, def response(self, _request: HttpParser) -> Optional[bytes]: pkt = [] if self.status_code is not None: - line = b'HTTP/1.1 ' + bytes_(self.status_code) + line = HTTP_1_1 + WHITESPACE + bytes_(self.status_code) if self.reason: line += WHITESPACE + self.reason pkt.append(line) @@ -1206,7 +1210,7 @@ def access_log(self) -> None: (self.client.addr[0], self.client.addr[1], text_(self.request.method), text_(server_host), server_port, - text_(self.request.build_url()), + text_(self.request.path), text_(self.response.code), text_(self.response.reason), self.response.total_size)) @@ -1245,7 +1249,11 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: else: return raw - def generate_upstream_certificate(self, _certificate: Optional[Dict[str, Any]]) -> Optional[str]: + @staticmethod + def generated_cert_file_path(ca_cert_dir: str, host: str) -> str: + return os.path.join(ca_cert_dir, '%s.pem' % host) + + def generate_upstream_certificate(self, _certificate: Optional[Dict[str, Any]]) -> str: if not (self.config.ca_cert_dir and self.config.ca_signing_key_file and self.config.ca_cert_file and self.config.ca_key_file): raise ProtocolException( @@ -1253,12 +1261,9 @@ def generate_upstream_certificate(self, _certificate: Optional[Dict[str, Any]]) f'--ca-cert-file:{ self.config.ca_cert_file }, ' f'--ca-key-file:{ self.config.ca_key_file }, ' f'--ca-signing-key-file:{ self.config.ca_signing_key_file }') + cert_file_path = HttpProxyPlugin.generated_cert_file_path( + self.config.ca_cert_dir, text_(self.request.host)) with self.lock: - cert_file_path = os.path.join( - self.config.ca_cert_dir, - '%s.pem' % - text_( - self.request.host)) if not os.path.isfile(cert_file_path): logger.debug('Generating certificates %s', cert_file_path) # TODO: Parse subject from certificate @@ -1275,7 +1280,7 @@ def generate_upstream_certificate(self, _certificate: Optional[Dict[str, Any]]) stderr=subprocess.PIPE) # TODO: Ensure sign_cert success. sign_cert.communicate(timeout=10) - return cert_file_path + return cert_file_path def on_request_complete(self) -> Union[socket.socket, bool]: if not self.request.has_upstream_server(): @@ -1378,9 +1383,6 @@ class WebsocketFrame: GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' def __init__(self) -> None: - self.reset() - - def reset(self) -> None: self.fin: bool = False self.rsv1: bool = False self.rsv2: bool = False @@ -1391,6 +1393,17 @@ def reset(self) -> None: self.mask: Optional[bytes] = None self.data: Optional[bytes] = None + def reset(self) -> None: + self.fin = False + self.rsv1 = False + self.rsv2 = False + self.rsv3 = False + self.opcode = 0 + self.masked = False + self.payload_length = None + self.mask = None + self.data = None + def parse_fin_and_rsv(self, byte: int) -> None: self.fin = bool(byte & 1 << 7) self.rsv1 = bool(byte & 1 << 6) @@ -1800,14 +1813,14 @@ def __init__( client: TcpClientConnection, request: HttpParser): super().__init__(config, client, request) - + self.pipeline_request: Optional[HttpParser] = None self.switched_protocol: Optional[int] = None - self.routes: Dict[int, Dict[bytes, HttpWebServerBasePlugin]] = { httpProtocolTypes.HTTP: {}, httpProtocolTypes.HTTPS: {}, httpProtocolTypes.WEBSOCKET: {}, } + self.route: Optional[HttpWebServerBasePlugin] = None if b'HttpWebServerBasePlugin' in self.config.plugins: for klass in self.config.plugins[b'HttpWebServerBasePlugin']: @@ -1815,7 +1828,7 @@ def __init__( for (protocol, path) in instance.routes(): self.routes[protocol][path] = instance - def serve_file_or_404(self, path: str) -> None: + def serve_file_or_404(self, path: str) -> bool: try: with open(path, 'rb') as f: content = f.read() @@ -1828,8 +1841,10 @@ def serve_file_or_404(self, path: str) -> None: b'Connection': b'close' }, body=content )) + return False except IOError: self.client.queue(self.DEFAULT_404_RESPONSE) + return True def try_upgrade(self) -> bool: if self.request.has_header(b'connection') and \ @@ -1850,10 +1865,10 @@ def on_request_complete(self) -> Union[socket.socket, bool]: if self.request.has_upstream_server(): return False - url = self.request.build_url() - # If a websocket route exists for the path, try upgrade - if url in self.routes[httpProtocolTypes.WEBSOCKET]: + if self.request.path in self.routes[httpProtocolTypes.WEBSOCKET]: + self.route = self.routes[httpProtocolTypes.WEBSOCKET][self.request.path] + # Connection upgrade teardown = self.try_upgrade() if teardown: @@ -1862,24 +1877,24 @@ def on_request_complete(self) -> Union[socket.socket, bool]: # For upgraded connections, nothing more to do if self.switched_protocol: # Invoke plugin.on_websocket_open - for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == url: - self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_open() + self.route.on_websocket_open() return False # Routing for Http(s) requests - protocol = httpProtocolTypes.HTTPS if self.config.certfile and self.config.keyfile else httpProtocolTypes.HTTP + protocol = httpProtocolTypes.HTTPS \ + if self.config.certfile and self.config.keyfile else \ + httpProtocolTypes.HTTP for r in self.routes[protocol]: - if r == url: - self.routes[protocol][r].handle_request(self.request) - return True + if r == self.request.path: + self.route = self.routes[protocol][r] + self.route.handle_request(self.request) + return False # No-route found, try static serving if enabled if self.config.enable_static_server: - path = text_(url).split('?')[0] + path = text_(self.request.path).split('?')[0] if os.path.isfile(self.config.static_server_dir + path): - self.serve_file_or_404(self.config.static_server_dir + path) - return True + return self.serve_file_or_404(self.config.static_server_dir + path) # Catch all unhandled web server requests, return 404 self.client.queue(self.DEFAULT_404_RESPONSE) @@ -1893,17 +1908,28 @@ def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: def on_client_data(self, raw: bytes) -> Optional[bytes]: if self.switched_protocol == httpProtocolTypes.WEBSOCKET: - url = self.request.build_url() remaining = raw frame = WebsocketFrame() while remaining != b'': # TODO: Teardown if invalid protocol exception remaining = frame.parse(remaining) for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == url: + if r == self.request.path: self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_message(frame) frame.reset() return None + # If 1st valid request was completed and it's a HTTP/1.1 keep-alive + elif self.request.state == httpParserStates.COMPLETE and \ + self.request.is_http_1_1_keep_alive(): + if self.pipeline_request is None: + self.pipeline_request = HttpParser(httpParserTypes.REQUEST_PARSER) + self.pipeline_request.parse(raw) + if self.pipeline_request.state == httpParserStates.COMPLETE: + assert self.route is not None + self.route.handle_request(self.pipeline_request) + if not self.pipeline_request.is_http_1_1_keep_alive(): + raise ProtocolException() + self.pipeline_request = None return raw def on_response_chunk(self, chunk: bytes) -> bytes: @@ -1915,13 +1941,16 @@ def on_client_connection_close(self) -> None: if self.switched_protocol: # Invoke plugin.on_websocket_close for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == self.request.build_url(): + if r == self.request.path: self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_close() + self.access_log() + + def access_log(self) -> None: logger.info( '%s:%s - %s %s' % (self.client.addr[0], self.client.addr[1], text_( self.request.method), text_( - self.request.build_url()))) + self.request.path))) def get_descriptors( self) -> Tuple[List[socket.socket], List[socket.socket]]: @@ -1989,12 +2018,6 @@ def optionally_wrap_socket( conn = ctx.wrap_socket(conn, server_side=True) return conn - def send_server_error(self, e: Exception) -> None: - logger.exception('Server error', exc_info=e) - self.client.queue(build_http_response( - 500, b'Server Error' - )) - def connection_inactive_for(self) -> int: return (self.now() - self.last_activity).seconds @@ -2031,17 +2054,22 @@ def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: self.client.closed = True return True - # ProtocolHandlerPlugin.on_client_data - plugin_index = 0 - plugins = list(self.plugins.values()) - while plugin_index < len(plugins) and client_data: - client_data = plugins[plugin_index].on_client_data(client_data) - if client_data is None: - break - plugin_index += 1 - - if client_data: - try: + try: + # ProtocolHandlerPlugin.on_client_data + # Can raise ProtocolException to teardown the connection + plugin_index = 0 + plugins = list(self.plugins.values()) + while plugin_index < len(plugins) and client_data: + client_data = plugins[plugin_index].on_client_data(client_data) + if client_data is None: + break + plugin_index += 1 + + # Don't parse request any further after 1st request has completed. + # This specially does happen for pipeline requests. + # Plugins can utilize on_client_data for such cases and + # apply custom logic to handle request data sent after 1st valid request. + if client_data and self.request.state != httpParserStates.COMPLETE: # Parse http request self.request.parse(client_data) if self.request.state == httpParserStates.COMPLETE: @@ -2059,17 +2087,13 @@ def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: 'Upgraded client conn for plugin %s', str(plugin_)) elif isinstance(upgraded_sock, bool) and upgraded_sock is True: return True - except ProtocolException as e: - logger.exception( - 'ProtocolException type raised', exc_info=e) - response = e.response(self.request) - if response: - self.client.queue(response) - else: - # If ProtocolException doesn't return a response, - # its a server error - self.send_server_error(e) - return True + except ProtocolException as e: + logger.exception( + 'ProtocolException type raised', exc_info=e) + response = e.response(self.request) + if response: + self.client.queue(response) + return True return False def get_events(self) -> Dict[socket.socket, int]: @@ -2246,7 +2270,7 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: def on_request_complete(self) -> Union[socket.socket, bool]: if not self.request.has_upstream_server() and \ - self.request.build_url() == self.config.devtools_ws_path: + self.request.path == self.config.devtools_ws_path: return False # Handle devtool frontend websocket upgrade @@ -2259,7 +2283,7 @@ def on_request_complete(self) -> Union[socket.socket, bool]: def on_response_chunk(self, chunk: bytes) -> bytes: if not self.request.has_upstream_server() and \ - self.request.build_url() == self.config.devtools_ws_path: + self.request.path == self.config.devtools_ws_path: return chunk if self.config.devtools_event_queue: @@ -2292,10 +2316,10 @@ def request_will_be_sent(self) -> Dict[str, Any]: 'documentURL': 'http://proxy-py', 'request': { 'url': text_( - self.request.build_url() + self.request.path if self.request.has_upstream_server() else b'http://' + bytes_(str(self.config.hostname)) + - COLON + bytes_(self.config.port) + self.request.build_url() + COLON + bytes_(self.config.port) + self.request.path ), 'urlFragment': '', 'method': text_(self.request.method), diff --git a/tests.py b/tests.py index 3ab61ee6e4..8d39c3ccd4 100644 --- a/tests.py +++ b/tests.py @@ -18,6 +18,7 @@ import ssl import tempfile import unittest +import uuid from contextlib import closing from http.server import HTTPServer, BaseHTTPRequestHandler from threading import Thread @@ -542,7 +543,7 @@ def test_has_header(self) -> None: def test_set_host_port_raises(self) -> None: with self.assertRaises(KeyError): - self.parser.set_host_port() + self.parser.set_line_attributes() def test_find_line(self) -> None: self.assertEqual( @@ -1231,6 +1232,76 @@ def mock_selector_for_client_read_read_server_write(self, mock_selector: mock.Mo data=None), selectors.EVENT_WRITE), ], ] + def assert_data_queued( + self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: + self.proxy.run_once() + self.assertEqual( + self.proxy.request.state, + proxy.httpParserStates.COMPLETE) + mock_server_connection.assert_called_once() + server.connect.assert_called_once() + server.closed = False + assert self.http_server_port is not None + pkt = proxy.CRLF.join([ + b'GET / HTTP/1.1', + b'User-Agent: proxy.py/%s' % proxy.version, + b'Host: localhost:%d' % self.http_server_port, + b'Accept: */*', + b'Via: %s' % b'1.1 proxy.py v%s' % proxy.version, + proxy.CRLF + ]) + server.queue.assert_called_once_with(pkt) + server.buffer_size.return_value = len(pkt) + + def assert_data_queued_to_server(self, server: mock.Mock) -> None: + assert self.http_server_port is not None + self.assertEqual( + self._conn.send.call_args[0][0], + proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) + + self._conn.recv.return_value = proxy.CRLF.join([ + b'GET / HTTP/1.1', + b'Host: localhost:%d' % self.http_server_port, + b'User-Agent: proxy.py/%s' % proxy.version, + proxy.CRLF + ]) + self.proxy.run_once() + + pkt = proxy.CRLF.join([ + b'GET / HTTP/1.1', + b'Host: localhost:%d' % self.http_server_port, + b'User-Agent: proxy.py/%s' % proxy.version, + proxy.CRLF + ]) + server.queue.assert_called_once_with(pkt) + server.buffer_size.return_value = len(pkt) + server.flush.assert_not_called() + + def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: + mock_selector.return_value.select.return_value = [( + selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ] + + +class TestWebServerPlugin(unittest.TestCase): + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self._conn = mock_fromfd.return_value + self.mock_selector = mock_selector + self.config = proxy.ProtocolConfig() + self.config.plugins = proxy.load_plugins( + b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') + self.proxy = proxy.ProtocolHandler( + self.fileno, self._addr, config=self.config) + self.proxy.initialize() + @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_pac_file_served_from_disk( @@ -1270,14 +1341,6 @@ def test_pac_file_served_from_buffer( }, body=pac_file_content )) - def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: - mock_selector.return_value.select.return_value = [( - selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ] - @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_default_web_server_returns_404( @@ -1427,50 +1490,13 @@ def init_and_make_pac_file_request(self, pac_file: str) -> None: proxy.CRLF, ]) - def assert_data_queued( - self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.COMPLETE) - mock_server_connection.assert_called_once() - server.connect.assert_called_once() - server.closed = False - assert self.http_server_port is not None - pkt = proxy.CRLF.join([ - b'GET / HTTP/1.1', - b'User-Agent: proxy.py/%s' % proxy.version, - b'Host: localhost:%d' % self.http_server_port, - b'Accept: */*', - b'Via: %s' % b'1.1 proxy.py v%s' % proxy.version, - proxy.CRLF - ]) - server.queue.assert_called_once_with(pkt) - server.buffer_size.return_value = len(pkt) - - def assert_data_queued_to_server(self, server: mock.Mock) -> None: - assert self.http_server_port is not None - self.assertEqual( - self._conn.send.call_args[0][0], - proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) - - self._conn.recv.return_value = proxy.CRLF.join([ - b'GET / HTTP/1.1', - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % proxy.version, - proxy.CRLF - ]) - self.proxy.run_once() - - pkt = proxy.CRLF.join([ - b'GET / HTTP/1.1', - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % proxy.version, - proxy.CRLF - ]) - server.queue.assert_called_once_with(pkt) - server.buffer_size.return_value = len(pkt) - server.flush.assert_not_called() + def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: + mock_selector.return_value.select.return_value = [( + selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ] class TestHttpProxyPlugin(unittest.TestCase): @@ -1551,20 +1577,23 @@ class TestHttpProxyTlsInterception(unittest.TestCase): @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') @mock.patch('proxy.TcpServerConnection') - @mock.patch('proxy.HttpProxyPlugin.generate_upstream_certificate') + @mock.patch('subprocess.Popen') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_e2e( self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, - mock_generate_certificate: mock.Mock, + mock_popen: mock.Mock, mock_server_conn: mock.Mock, mock_ssl_context: mock.Mock, mock_ssl_wrap: mock.Mock) -> None: + host, port = uuid.uuid4().hex, 443 + netloc = '{0}:{1}'.format(host, port) + self.mock_fromfd = mock_fromfd self.mock_selector = mock_selector - self.mock_generate_certificate = mock_generate_certificate + self.mock_popen = mock_popen self.mock_server_conn = mock_server_conn self.mock_ssl_context = mock_ssl_context self.mock_ssl_wrap = mock_ssl_wrap @@ -1608,9 +1637,9 @@ def mock_connection() -> Any: self.assertEqual(self.proxy_plugin.call_args[0][1].connection, self._conn) connect_request = proxy.build_http_request( - proxy.httpMethods.CONNECT, b'super.secure:443', + proxy.httpMethods.CONNECT, proxy.bytes_(netloc), headers={ - b'Host': b'super.secure:443', + b'Host': proxy.bytes_(netloc), }) self._conn.recv.return_value = connect_request @@ -1643,7 +1672,7 @@ def mock_connection() -> Any: self.proxy_plugin.return_value.before_upstream_connection.assert_called() self.proxy_plugin.return_value.on_upstream_connection.assert_called() - self.mock_server_conn.assert_called_with('super.secure', 443) + self.mock_server_conn.assert_called_with(host, port) self.mock_server_conn.return_value.connection.setblocking.assert_called_with(False) self.mock_ssl_context.assert_called_with(ssl.Purpose.SERVER_AUTH) @@ -1651,26 +1680,27 @@ def mock_connection() -> Any: # ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1) self.assertEqual(plain_connection.setblocking.call_count, 2) self.mock_ssl_context.return_value.wrap_socket.assert_called_with( - plain_connection, server_hostname='super.secure') + plain_connection, server_hostname=host) + # TODO: Assert Popen arguments, piping, success condition + self.assertEqual(self.mock_popen.call_count, 2) self.assertEqual(ssl_connection.setblocking.call_count, 1) self.assertEqual(self.mock_server_conn.return_value._conn, ssl_connection) - self.mock_generate_certificate.assert_called_with( - self.mock_server_conn.return_value.connection.getpeercert.return_value) self._conn.send.assert_called_with(proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) + assert self.config.ca_cert_dir is not None self.mock_ssl_wrap.assert_called_with( self._conn, server_side=True, keyfile=self.config.ca_signing_key_file, - certfile=self.mock_generate_certificate.return_value + certfile=proxy.HttpProxyPlugin.generated_cert_file_path( + self.config.ca_cert_dir, host) ) self.assertEqual(self._conn.setblocking.call_count, 2) self.assertEqual(self.proxy.client.connection, self.mock_ssl_wrap.return_value) # Assert connection references for all other plugins is updated self.assertEqual(self.plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) - - # Currently proxy doesn't update it's own plugin - # self.assertEqual(self.proxy_plugin.return_value.client._conn, self._conn) + # Currently proxy doesn't update it's own plugin connection reference + # self.assertEqual(self.proxy_plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) class TestHttpRequestRejected(unittest.TestCase): From 3b5e2ccf9c8bc1666568b4fad019079ab5647761 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 11 Oct 2019 11:03:51 -0700 Subject: [PATCH 004/107] Reduce docker image size --- Dockerfile | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 291ae291a8..c0378f2189 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,20 @@ -FROM python:3-alpine +FROM python:3.7-alpine as base +FROM base as builder + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install --install-option="--prefix=/deps" -r requirements.txt + +FROM base + LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ com.abhinavsingh.description="⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file" \ com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py" +COPY --from=builder /deps /usr/local +COPY proxy.py /app/ WORKDIR /app -COPY requirements.txt . -COPY proxy.py . - -RUN pip install --upgrade pip && pip install -r requirements.txt - EXPOSE 8899/tcp ENTRYPOINT [ "./proxy.py" ] CMD [ "--hostname=0.0.0.0", \ From 2840afc0bf49b024a1cccb754bc0d09ae6c64311 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 11 Oct 2019 11:04:30 -0700 Subject: [PATCH 005/107] Ensure teardown is always accompanied with Connection: close header Fix tests --- proxy.py | 18 ++++++++++-------- tests.py | 1 - 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/proxy.py b/proxy.py index 0ba62f21e3..156c930ae0 100755 --- a/proxy.py +++ b/proxy.py @@ -50,7 +50,7 @@ PROXY_PY_DIR = os.path.dirname(os.path.realpath(__file__)) PROXY_PY_START_TIME = time.time() -VERSION = (1, 1, 1) +VERSION = (1, 1, 2) __version__ = '.'.join(map(str, VERSION[0:3])) __description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' __author__ = 'Abhinav Singh' @@ -888,8 +888,10 @@ class ProxyConnectionFailed(ProtocolException): RESPONSE_PKT = build_http_response( 502, reason=b'Bad Gateway', - headers={PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close'}, + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close' + }, body=b'Bad Gateway' ) @@ -908,9 +910,11 @@ class ProxyAuthenticationFailed(ProtocolException): RESPONSE_PKT = build_http_response( 407, reason=b'Proxy Authentication Required', - headers={PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close', - b'Proxy-Authenticate': b'Basic'}, + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + b'Proxy-Authenticate': b'Basic', + b'Connection': b'close', + }, body=b'Proxy Authentication Required') def response(self, _request: HttpParser) -> bytes: @@ -1768,7 +1772,6 @@ def cache_pac_file_response(self) -> None: self.pac_file_response = build_http_response( 200, reason=b'OK', headers={ b'Content-Type': b'application/x-ns-proxy-autoconfig', - b'Connection': b'close' }, body=content ) @@ -1838,7 +1841,6 @@ def serve_file_or_404(self, path: str) -> bool: self.client.queue(build_http_response( 200, reason=b'OK', headers={ b'Content-Type': bytes_(content_type), - b'Connection': b'close' }, body=content )) return False diff --git a/tests.py b/tests.py index 8d39c3ccd4..aa6e27bb36 100644 --- a/tests.py +++ b/tests.py @@ -1420,7 +1420,6 @@ def test_static_web_server_serves( self.assertEqual(self._conn.send.call_args[0][0], proxy.build_http_response( 200, reason=b'OK', headers={ b'Content-Type': b'text/html', - b'Connection': b'close', b'Content-Length': proxy.bytes_(len(html_file_content)) }, body=html_file_content From 6455a3497350f5aff89454766031a400dbccf37c Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 12 Oct 2019 17:43:19 -0700 Subject: [PATCH 006/107] Invoke proxy plugin handle_request for each request in HTTP/1.1 pipeline or when TLS interception is enabled (#128) * Add tests for is_http_1_1_keep_alive * Add ModifyPostDataPlugin in README * Fixes #126 * Refactor HttpProxyBasePlugin API * before_upstream_connection too can drop request by returning None * Remove HTTP Server startup during tests, no longer used * Removed unused imports * Simplify load_plugins --- .github/ISSUE_TEMPLATE/bug_report.md | 3 +- .github/ISSUE_TEMPLATE/feature_request.md | 6 +- README.md | 104 ++++++++++---- plugin_examples.py | 138 ++++++++++--------- proxy.py | 157 +++++++++++++++------- tests.py | 90 ++++++------- 6 files changed, 311 insertions(+), 187 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9a7165bb8c..b60297fa5e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,7 +8,8 @@ assignees: abhinavsingh --- **Check FAQs** -Please check [Frequently Asked Questions](https://github.com/abhinavsingh/proxy.py#frequently-asked-questions) before filling a bug. +Please check [Frequently Asked Questions](https://github.com/abhinavsingh/proxy.py#frequently-asked-questions) +before opening a bug report. **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6b80f3081f..2ef678f750 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,12 +1,16 @@ --- name: Feature request -about: Suggest an idea for this project +about: Suggest an idea for proxy.py title: '' labels: Enhancement assignees: abhinavsingh --- +**Check FAQs** +Please check [Frequently Asked Questions](https://github.com/abhinavsingh/proxy.py#frequently-asked-questions) +before opening a feature request. + **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] diff --git a/README.md b/README.md index 4bd53a667f..32311a088b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Table of Contents * [Stable version](#stable-version-from-docker-hub) * [Development version](#build-development-version-locally) * [Plugin Examples](#plugin-examples) + * [ModifyPostDataPlugin](#modifypostdataplugin) * [ProposedRestApiPlugin](#proposedrestapiplugin) * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) * [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin) @@ -203,6 +204,58 @@ See [plugin_examples.py](https://github.com/abhinavsingh/proxy.py/blob/develop/p All the examples below also works with `https` traffic but require additional flags and certificate generation. See [TLS Interception](#tls-interception). +## ModifyPostDataPlugin + +Modifies POST request body before sending request to upstream server. + +Start `proxy.py` as: + +``` +$ proxy.py \ + --plugins plugin_examples.ModifyPostDataPlugin +``` + +By default plugin replaces POST body content with hardcoded `b'{"key": "modified"}'` +and enforced `Content-Type: application/json`. + +Verify the same using `curl -x localhost:8899 -d '{"key": "value"}' http://httpbin.org/post` + +``` +{ + "args": {}, + "data": "{\"key\": \"modified\"}", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Content-Length": "19", + "Content-Type": "application/json", + "Host": "httpbin.org", + "User-Agent": "curl/7.54.0" + }, + "json": { + "key": "modified" + }, + "origin": "1.2.3.4, 5.6.7.8", + "url": "https://httpbin.org/post" +} +``` + +Note following from the response above: + +1. POST data was modified `"data": "{\"key\": \"modified\"}"`. + Original `curl` command data was `{"key": "value"}`. +2. Our `curl` command didn't add any `Content-Type` header, + but our plugin did add one `"Content-Type": "application/json"`. + Same can also be verified by looking at `json` field in the output above: + ``` + "json": { + "key": "modified" + }, + ``` +3. Our plugin also added a `Content-Length` header to match length + of modified body. + ## ProposedRestApiPlugin Mock responses for your server REST API. @@ -332,13 +385,13 @@ Verify using `curl -v -x localhost:8899 http://httpbin.org/get`: < Connection: keep-alive < { - "args": {}, + "args": {}, "headers": { - "Accept": "*/*", - "Host": "httpbin.org", + "Accept": "*/*", + "Host": "httpbin.org", "User-Agent": "curl/7.54.0" - }, - "origin": "1.2.3.4, 5.6.7.8", + }, + "origin": "1.2.3.4, 5.6.7.8", "url": "https://httpbin.org/get" } * Connection #0 to host localhost left intact @@ -368,13 +421,13 @@ Content-Length: 202 Connection: keep-alive { - "args": {}, + "args": {}, "headers": { - "Accept": "*/*", - "Host": "httpbin.org", + "Accept": "*/*", + "Host": "httpbin.org", "User-Agent": "curl/7.54.0" - }, - "origin": "1.2.3.4, 5.6.7.8", + }, + "origin": "1.2.3.4, 5.6.7.8", "url": "https://httpbin.org/get" } ``` @@ -443,13 +496,13 @@ Verify using `curl -x https://localhost:8899 --proxy-cacert https-cert.pem https ``` { - "args": {}, + "args": {}, "headers": { - "Accept": "*/*", - "Host": "httpbin.org", + "Accept": "*/*", + "Host": "httpbin.org", "User-Agent": "curl/7.54.0" - }, - "origin": "1.2.3.4, 5.6.7.8", + }, + "origin": "1.2.3.4, 5.6.7.8", "url": "https://httpbin.org/get" } ``` @@ -485,13 +538,13 @@ Verify using `curl -v -x localhost:8899 --cacert ca-cert.pem https://httpbin.org < Connection: keep-alive < { - "args": {}, + "args": {}, "headers": { - "Accept": "*/*", - "Host": "httpbin.org", + "Accept": "*/*", + "Host": "httpbin.org", "User-Agent": "curl/7.54.0" - }, - "origin": "1.2.3.4, 5.6.7.8", + }, + "origin": "1.2.3.4, 5.6.7.8", "url": "https://httpbin.org/get" } ``` @@ -518,16 +571,15 @@ Content-Length: 202 Connection: keep-alive { - "args": {}, + "args": {}, "headers": { - "Accept": "*/*", - "Host": "httpbin.org", + "Accept": "*/*", + "Host": "httpbin.org", "User-Agent": "curl/7.54.0" - }, - "origin": "1.2.3.4, 5.6.7.8", + }, + "origin": "1.2.3.4, 5.6.7.8", "url": "https://httpbin.org/get" } - ``` Viola!!! If you remove CA flags, encrypted data will be found in the diff --git a/plugin_examples.py b/plugin_examples.py index 1751dd7b55..d7029ec5f5 100644 --- a/plugin_examples.py +++ b/plugin_examples.py @@ -21,19 +21,23 @@ class ModifyPostDataPlugin(proxy.HttpProxyBasePlugin): MODIFIED_BODY = b'{"key": "modified"}' - def before_upstream_connection(self) -> bool: - if self.request.method == proxy.httpMethods.POST: - self.request.body = ModifyPostDataPlugin.MODIFIED_BODY - # Update Content-Length header only when request is NOT chunked encoded - if not self.request.is_chunked_encoded(): - self.request.add_header(b'Content-Length', proxy.bytes_(len(self.request.body))) - return False - - def on_upstream_connection(self) -> None: - pass + def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request - def handle_upstream_response(self, raw: bytes) -> bytes: - return raw + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + if request.method == proxy.httpMethods.POST: + request.body = ModifyPostDataPlugin.MODIFIED_BODY + # Update Content-Length header only when request is NOT chunked encoded + if not request.is_chunked_encoded(): + request.add_header(b'Content-Length', proxy.bytes_(len(request.body))) + # Enforce content-type json + if request.has_header(b'Content-Type'): + request.del_header(b'Content-Type') + request.add_header(b'Content-Type', b'application/json') + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk def on_upstream_connection_close(self) -> None: pass @@ -71,29 +75,28 @@ class ProposedRestApiPlugin(proxy.HttpProxyBasePlugin): }, } - def before_upstream_connection(self) -> bool: - """Called after client request is received and - before connecting to upstream server.""" - if self.request.host == self.API_SERVER and self.request.url: - if self.request.url.path in self.REST_API_SPEC: - self.client.send(proxy.build_http_response( - 200, reason=b'OK', - headers={b'Content-Type': b'application/json'}, - body=proxy.bytes_(json.dumps( - self.REST_API_SPEC[self.request.url.path])) - )) - else: - self.client.send(proxy.build_http_response( - 404, reason=b'NOT FOUND', body=b'Not Found' - )) - return True - return False - - def on_upstream_connection(self) -> None: - pass - - def handle_upstream_response(self, raw: bytes) -> bytes: - return raw + def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + if request.host != self.API_SERVER: + return request + assert request.path + if request.path in self.REST_API_SPEC: + self.client.queue(proxy.build_http_response( + 200, reason=b'OK', + headers={b'Content-Type': b'application/json'}, + body=proxy.bytes_(json.dumps( + self.REST_API_SPEC[request.path])) + )) + else: + self.client.queue(proxy.build_http_response( + 404, reason=b'NOT FOUND', body=b'Not Found' + )) + return None + + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk def on_upstream_connection_close(self) -> None: pass @@ -104,20 +107,20 @@ class RedirectToCustomServerPlugin(proxy.HttpProxyBasePlugin): UPSTREAM_SERVER = b'http://localhost:8899' - def before_upstream_connection(self) -> bool: + def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: # Redirect all non-https requests to inbuilt WebServer. - if self.request.method != proxy.httpMethods.CONNECT: - self.request.url = urlparse.urlsplit(self.UPSTREAM_SERVER) + if request.method != proxy.httpMethods.CONNECT: + request.url = urlparse.urlsplit(self.UPSTREAM_SERVER) # This command will re-parse modified url and # update host, port, path fields - self.request.set_line_attributes() - return False + request.set_line_attributes() + return request - def on_upstream_connection(self) -> None: - pass + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request - def handle_upstream_response(self, raw: bytes) -> bytes: - return raw + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk def on_upstream_connection_close(self) -> None: pass @@ -128,17 +131,17 @@ class FilterByUpstreamHostPlugin(proxy.HttpProxyBasePlugin): FILTERED_DOMAINS = [b'google.com', b'www.google.com'] - def before_upstream_connection(self) -> bool: - if self.request.host in self.FILTERED_DOMAINS: + def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + if request.host in self.FILTERED_DOMAINS: raise proxy.HttpRequestRejected( status_code=418, reason=b'I\'m a tea pot') - return False + return request - def on_upstream_connection(self) -> None: - pass + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request - def handle_upstream_response(self, raw: bytes) -> bytes: - return raw + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk def on_upstream_connection_close(self) -> None: pass @@ -149,22 +152,27 @@ class CacheResponsesPlugin(proxy.HttpProxyBasePlugin): CACHE_DIR = tempfile.gettempdir() - def __init__(self, config: proxy.ProtocolConfig, client: proxy.TcpClientConnection, - request: proxy.HttpParser) -> None: - super().__init__(config, client, request) + def __init__( + self, + config: proxy.ProtocolConfig, + client: proxy.TcpClientConnection) -> None: + super().__init__(config, client) self.cache_file_path: Optional[str] = None self.cache_file: Optional[BinaryIO] = None - def before_upstream_connection(self) -> bool: - return False - - def on_upstream_connection(self) -> None: + def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + # Ideally should only create file if upstream connection succeeds. self.cache_file_path = os.path.join( self.CACHE_DIR, - '%s-%s.txt' % (proxy.text_(self.request.host), str(time.time()))) + '%s-%s.txt' % (proxy.text_(request.host), str(time.time()))) self.cache_file = open(self.cache_file_path, "wb") + return request + + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request - def handle_upstream_response(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, + chunk: bytes) -> bytes: if self.cache_file: self.cache_file.write(chunk) return chunk @@ -178,13 +186,13 @@ def on_upstream_connection_close(self) -> None: class ManInTheMiddlePlugin(proxy.HttpProxyBasePlugin): """Modifies upstream server responses.""" - def before_upstream_connection(self) -> bool: - return False + def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request - def on_upstream_connection(self) -> None: - pass + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + return request - def handle_upstream_response(self, raw: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: bytes) -> bytes: return proxy.build_http_response( 200, reason=b'OK', body=b'Hello from man in the middle') diff --git a/proxy.py b/proxy.py index 156c930ae0..f9ec00e043 100755 --- a/proxy.py +++ b/proxy.py @@ -142,19 +142,25 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: HttpMethods = NamedTuple('HttpMethods', [ ('GET', bytes), - ('PUT', bytes), - ('POST', bytes), - ('CONNECT', bytes), ('HEAD', bytes), + ('POST', bytes), + ('PUT', bytes), ('DELETE', bytes), + ('CONNECT', bytes), + ('OPTIONS', bytes), + ('TRACE', bytes), + ('PATCH', bytes), ]) httpMethods = HttpMethods( b'GET', - b'PUT', - b'POST', - b'CONNECT', b'HEAD', + b'POST', + b'PUT', b'DELETE', + b'CONNECT', + b'OPTIONS', + b'TRACE', + b'PATCH', ) HttpParserStates = NamedTuple('HttpParserStates', [ @@ -997,6 +1003,16 @@ def __init__( self.proxy_py_data_dir, self.GENERATED_CERTS_DIR_NAME) os.makedirs(self.ca_cert_dir, exist_ok=True) + def tls_interception_enabled(self) -> bool: + return self.ca_key_file is not None and \ + self.ca_cert_dir is not None and \ + self.ca_signing_key_file is not None and \ + self.ca_cert_file is not None + + def encryption_enabled(self) -> bool: + return self.keyfile is not None and \ + self.certfile is not None + class ProtocolHandlerPlugin(ABC): """Base ProtocolHandler Plugin class. @@ -1079,11 +1095,9 @@ class HttpProxyBasePlugin(ABC): def __init__( self, config: ProtocolConfig, - client: TcpClientConnection, - request: HttpParser): + client: TcpClientConnection): self.config = config # pragma: no cover self.client = client # pragma: no cover - self.request = request # pragma: no cover def name(self) -> str: """A unique name for your plugin. @@ -1093,24 +1107,38 @@ def name(self) -> str: return self.__class__.__name__ # pragma: no cover @abstractmethod - def before_upstream_connection(self) -> bool: + def before_upstream_connection(self, request: HttpParser) -> Optional[HttpParser]: """Handler called just before Proxy upstream connection is established. - Raise HttpRequestRejected to drop the connection.""" - pass # pragma: no cover + Return optionally modified request object. + Raise HttpRequestRejected or ProtocolException directly to drop the connection.""" + return request # pragma: no cover @abstractmethod - def on_upstream_connection(self) -> None: - """Handler called right after upstream connection has been established.""" - pass # pragma: no cover + def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: + """Handler called before dispatching client request to upstream. + + Note: For pipelined (keep-alive) connections, this handler can be + called multiple times, for each request sent to upstream. + + Note: If TLS interception is enabled, this handler can + be called multiple times if client exchanges multiple + requests over same SSL session. + + Return optionally modified request object to dispatch to upstream. + Return None to drop the request data, e.g. in case a response has already been queued. + Raise HttpRequestRejected or ProtocolException directly to + teardown the connection with client. + """ + return request @abstractmethod - def handle_upstream_response(self, raw: bytes) -> bytes: - """Handled called right after reading response from upstream server and - before queuing that response to client. + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + """Handler called right after receiving raw response from upstream server. - Optionally return modified response to queue for client.""" - return raw # pragma: no cover + For HTTPS connections, chunk will be encrypted unless + TLS interception is also enabled.""" + return chunk # pragma: no cover @abstractmethod def on_upstream_connection_close(self) -> None: @@ -1135,13 +1163,15 @@ def __init__( client: TcpClientConnection, request: HttpParser): super().__init__(config, client, request) + self.start_time: float = time.time() self.server: Optional[TcpServerConnection] = None self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) + self.pipeline_request: Optional[HttpParser] = None self.plugins: Dict[str, HttpProxyBasePlugin] = {} if b'HttpProxyBasePlugin' in self.config.plugins: for klass in self.config.plugins[b'HttpProxyBasePlugin']: - instance = klass(self.config, self.client, self.request) + instance = klass(self.config, self.client) self.plugins[instance.name()] = instance def get_descriptors( @@ -1183,11 +1213,13 @@ def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: return True for plugin in self.plugins.values(): - raw = plugin.handle_upstream_response(raw) + raw = plugin.handle_upstream_chunk(raw) # parse incoming response packet - # only for non-https requests - if not self.request.method == httpMethods.CONNECT: + # only for non-https requests and when + # tls interception is enabled + if self.request.method != httpMethods.CONNECT or \ + self.config.tls_interception_enabled(): self.response.parse(raw) else: self.response.total_size += len(raw) @@ -1198,26 +1230,28 @@ def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: def access_log(self) -> None: server_host, server_port = self.server.addr if self.server else ( None, None) + connection_time_ms = (time.time() - self.start_time) * 1000 if self.request.method == b'CONNECT': logger.info( - '%s:%s - %s %s:%s - %s bytes' % + '%s:%s - %s %s:%s - %s bytes - %.2f ms' % (self.client.addr[0], self.client.addr[1], - text_( - self.request.method), + text_(self.request.method), text_(server_host), text_(server_port), - self.response.total_size)) + self.response.total_size, + connection_time_ms)) elif self.request.method: logger.info( - '%s:%s - %s %s:%s%s - %s %s - %s bytes' % + '%s:%s - %s %s:%s%s - %s %s - %s bytes - %.2f ms' % (self.client.addr[0], self.client.addr[1], text_(self.request.method), text_(server_host), server_port, text_(self.request.path), text_(self.response.code), text_(self.response.reason), - self.response.total_size)) + self.response.total_size, + connection_time_ms)) def on_client_connection_close(self) -> None: if not self.request.has_upstream_server(): @@ -1248,7 +1282,27 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: return raw if self.server and not self.server.closed: - self.server.queue(raw) + # If 1st request did reach completion stage + # and 1st request was not a CONNECT request + # or if TLS interception was enabled + if self.request.state == httpParserStates.COMPLETE and ( + self.request.method != httpMethods.CONNECT or + self.config.tls_interception_enabled()): + if self.pipeline_request is None: + self.pipeline_request = HttpParser(httpParserTypes.REQUEST_PARSER) + self.pipeline_request.parse(raw) + if self.pipeline_request.state == httpParserStates.COMPLETE: + for plugin in self.plugins.values(): + assert self.pipeline_request is not None + r = plugin.handle_client_request(self.pipeline_request) + if r is None: + return None + self.pipeline_request = r + assert self.pipeline_request is not None + self.server.queue(self.pipeline_request.build()) + self.pipeline_request = None + else: + self.server.queue(raw) return None else: return raw @@ -1293,21 +1347,27 @@ def on_request_complete(self) -> Union[socket.socket, bool]: # Note: can raise HttpRequestRejected exception # Invoke plugin.before_upstream_connection for plugin in self.plugins.values(): - teardown = plugin.before_upstream_connection() - if teardown: - return teardown + r = plugin.before_upstream_connection(self.request) + if r is None: + return False + self.request = r self.authenticate() self.connect_upstream() for plugin in self.plugins.values(): - plugin.on_upstream_connection() + assert self.request is not None + r = plugin.handle_client_request(self.request) + if r is not None: + self.request = r + else: + return False if self.request.method == httpMethods.CONNECT: self.client.queue( HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) # If interception is enabled - if self.config.ca_key_file and self.config.ca_cert_file and self.config.ca_signing_key_file: + if self.config.tls_interception_enabled(): assert self.server is not None assert isinstance(self.server.connection, socket.socket) # Perform SSL/TLS handshake with upstream @@ -1816,6 +1876,7 @@ def __init__( client: TcpClientConnection, request: HttpParser): super().__init__(config, client, request) + self.start_time: float = time.time() self.pipeline_request: Optional[HttpParser] = None self.switched_protocol: Optional[int] = None self.routes: Dict[int, Dict[bytes, HttpWebServerBasePlugin]] = { @@ -1884,7 +1945,7 @@ def on_request_complete(self) -> Union[socket.socket, bool]: # Routing for Http(s) requests protocol = httpProtocolTypes.HTTPS \ - if self.config.certfile and self.config.keyfile else \ + if self.config.encryption_enabled() else \ httpProtocolTypes.HTTP for r in self.routes[protocol]: if r == self.request.path: @@ -1950,9 +2011,10 @@ def on_client_connection_close(self) -> None: def access_log(self) -> None: logger.info( '%s:%s - %s %s' % - (self.client.addr[0], self.client.addr[1], text_( - self.request.method), text_( - self.request.path))) + (self.client.addr[0], + self.client.addr[1], + text_(self.request.method), + text_(self.request.path))) def get_descriptors( self) -> Tuple[List[socket.socket], List[socket.socket]]: @@ -2009,11 +2071,12 @@ def optionally_wrap_socket( Shutdown and closes client connection upon error. """ - if self.config.certfile and self.config.keyfile: + if self.config.encryption_enabled(): ctx = ssl.create_default_context( ssl.Purpose.CLIENT_AUTH) ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 ctx.verify_mode = ssl.CERT_NONE + assert self.config.keyfile and self.config.certfile ctx.load_cert_chain( certfile=self.config.certfile, keyfile=self.config.keyfile) @@ -2433,14 +2496,10 @@ def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]: if plugin == '': continue module_name, klass_name = plugin.rsplit(text_(DOT), 1) - if module_name == 'proxy': - klass = getattr( - importlib.import_module(__name__), - klass_name) - else: - klass = getattr( - importlib.import_module(module_name), - klass_name) + klass = getattr( + importlib.import_module( + __name__ if module_name == 'proxy' else module_name), + klass_name) base_klass = inspect.getmro(klass)[1] p[bytes_(base_klass.__name__)].append(klass) logger.info( diff --git a/tests.py b/tests.py index aa6e27bb36..015c4f3821 100644 --- a/tests.py +++ b/tests.py @@ -20,9 +20,7 @@ import unittest import uuid from contextlib import closing -from http.server import HTTPServer, BaseHTTPRequestHandler -from threading import Thread -from typing import Dict, Optional, Tuple, Union, Any +from typing import Dict, Optional, Tuple, Union, Any, cast from unittest import mock import proxy @@ -880,7 +878,6 @@ def test_chunked_response_parse(self) -> None: self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) def test_chunked_request_parse(self) -> None: - self.parser.type = proxy.httpParserTypes.REQUEST_PARSER self.parser.parse(proxy.build_http_request( proxy.httpMethods.POST, b'http://example.org/', headers={ @@ -898,6 +895,36 @@ def test_chunked_request_parse(self) -> None: }, body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n')) + def test_is_http_1_1_keep_alive(self) -> None: + self.parser.parse(proxy.build_http_request( + proxy.httpMethods.GET, b'/' + )) + self.assertTrue(self.parser.is_http_1_1_keep_alive()) + + def test_is_http_1_1_keep_alive_with_non_close_connection_header(self) -> None: + self.parser.parse(proxy.build_http_request( + proxy.httpMethods.GET, b'/', + headers={ + b'Connection': b'keep-alive', + } + )) + self.assertTrue(self.parser.is_http_1_1_keep_alive()) + + def test_is_not_http_1_1_keep_alive_with_close_header(self) -> None: + self.parser.parse(proxy.build_http_request( + proxy.httpMethods.GET, b'/', + headers={ + b'Connection': b'close', + } + )) + self.assertFalse(self.parser.is_http_1_1_keep_alive()) + + def test_is_not_http_1_1_keep_alive_for_http_1_0(self) -> None: + self.parser.parse(proxy.build_http_request( + proxy.httpMethods.GET, b'/', protocol_version=b'HTTP/1.0', + )) + self.assertFalse(self.parser.is_http_1_1_keep_alive()) + def assertDictContainsSubset(self, subset: Dict[bytes, Tuple[bytes, bytes]], dictionary: Dict[bytes, Tuple[bytes, bytes]]) -> None: for k in subset.keys(): @@ -948,40 +975,6 @@ def test_handshake(self, mock_connect: mock.Mock, mock_b64encode: mock.Mock) -> class TestHttpProtocolHandler(unittest.TestCase): - http_server = None - http_server_port = None - http_server_thread = None - config = None - - class HTTPRequestHandler(BaseHTTPRequestHandler): - - def do_GET(self) -> None: - self.send_response(200) - # TODO(abhinavsingh): Proxy should work just fine even without - # content-length header - self.send_header('content-length', '2') - self.end_headers() - self.wfile.write(b'OK') - - @classmethod - def setUpClass(cls) -> None: - cls.http_server_port = get_available_port() - cls.http_server = HTTPServer( - ('127.0.0.1', cls.http_server_port), TestHttpProtocolHandler.HTTPRequestHandler) - cls.http_server_thread = Thread(target=cls.http_server.serve_forever) - cls.http_server_thread.setDaemon(True) - cls.http_server_thread.start() - cls.config = proxy.ProtocolConfig() - cls.config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - - @classmethod - def tearDownClass(cls) -> None: - if cls.http_server: - cls.http_server.shutdown() - cls.http_server.server_close() - if cls.http_server_thread: - cls.http_server_thread.join() @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @@ -989,6 +982,12 @@ def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self._conn = mock_fromfd.return_value + + self.http_server_port = 65535 + self.config = proxy.ProtocolConfig() + self.config.plugins = proxy.load_plugins( + b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') + self.mock_selector = mock_selector self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) @@ -1030,7 +1029,7 @@ def assert_tunnel_response( self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: self.proxy.run_once() self.assertTrue( - self.proxy.plugins['HttpProxyPlugin'].server is not None) # type: ignore + cast(proxy.HttpProxyPlugin, self.proxy.plugins['HttpProxyPlugin']).server is not None) self.assertEqual( self.proxy.client.buffer, proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) @@ -1528,8 +1527,8 @@ def test_proxy_plugin_initialized(self) -> None: def test_proxy_plugin_on_and_before_upstream_connection( self, mock_server_conn: mock.Mock) -> None: - self.plugin.return_value.before_upstream_connection.return_value = False - self.plugin.return_value.on_upstream_connection.return_value = None + self.plugin.return_value.before_upstream_connection.side_effect = lambda r: r + self.plugin.return_value.handle_client_request.side_effect = lambda r: r self._conn.recv.return_value = proxy.build_http_request( b'GET', b'http://upstream.host/not-found.html', @@ -1546,13 +1545,13 @@ def test_proxy_plugin_on_and_before_upstream_connection( self.proxy.run_once() mock_server_conn.assert_called_with('upstream.host', 80) self.plugin.return_value.before_upstream_connection.assert_called() - self.plugin.return_value.on_upstream_connection.assert_called() + self.plugin.return_value.handle_client_request.assert_called() @mock.patch('proxy.TcpServerConnection') def test_proxy_plugin_before_upstream_connection_can_teardown( self, mock_server_conn: mock.Mock) -> None: - self.plugin.return_value.before_upstream_connection.return_value = True + self.plugin.return_value.before_upstream_connection.side_effect = proxy.ProtocolException() self._conn.recv.return_value = proxy.build_http_request( b'GET', b'http://upstream.host/not-found.html', @@ -1652,7 +1651,8 @@ def mock_connection() -> Any: self.plugin.return_value.on_client_connection_close.return_value = None # Prepare mocked HttpProxyBasePlugin - self.proxy_plugin.return_value.before_upstream_connection.return_value = False + self.proxy_plugin.return_value.before_upstream_connection.side_effect = lambda r: r + self.proxy_plugin.return_value.handle_client_request.side_effect = lambda r: r self.mock_selector.return_value.select.side_effect = [ [(selectors.SelectorKey( @@ -1669,7 +1669,7 @@ def mock_connection() -> Any: self.plugin.return_value.on_request_complete.assert_called() self.plugin.return_value.read_from_descriptors.assert_called_with([self._conn]) self.proxy_plugin.return_value.before_upstream_connection.assert_called() - self.proxy_plugin.return_value.on_upstream_connection.assert_called() + self.proxy_plugin.return_value.handle_client_request.assert_called() self.mock_server_conn.assert_called_with(host, port) self.mock_server_conn.return_value.connection.setblocking.assert_called_with(False) From dc560be6ea140ad3eb551922bab637e735200da4 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 12 Oct 2019 21:02:17 -0700 Subject: [PATCH 007/107] Add --timeout flag with default value of 10 second. (#129) * Add --timeout flag with default value of 5. This value was previously hardcoded to 30 * --timeout=10 by default * Dispatch 408 timeout when connection is dropped due to inactivity * Add httpStatusCodes named tuple * Update plugin client connection reference after TLS connection upgrade --- .gitignore | 3 +- README.md | 8 ++- plugin_examples.py | 17 +++--- proxy.py | 127 ++++++++++++++++++++++++++++++++------------- tests.py | 7 +-- 5 files changed, 115 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index 8c5ab624c4..9a8ba1c0db 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ build proxy.py.egg-info proxy.py.iml *.pyc -*.pem +ca-*.pem +https-*.pem benchmark.py diff --git a/README.md b/README.md index 32311a088b..ea57228e7a 100644 --- a/README.md +++ b/README.md @@ -905,9 +905,10 @@ usage: proxy.py [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] [--pac-file-url-path PAC_FILE_URL_PATH] [--pid-file PID_FILE] [--plugins PLUGINS] [--port PORT] [--server-recvbuf-size SERVER_RECVBUF_SIZE] - [--static-server-dir STATIC_SERVER_DIR] [--version] + [--static-server-dir STATIC_SERVER_DIR] [--timeout TIMEOUT] + [--version] -proxy.py v1.1.0 +proxy.py v1.2.0 optional arguments: -h, --help show this help message and exit @@ -994,6 +995,9 @@ optional arguments: server root directory. This option is only applicable when static server is also enabled. See --enable- static-server. + --timeout TIMEOUT Default: 10. Number of seconds after which an inactive + connection must be dropped. Inactivity is defined by + no data sent or received by the client. --version, -v Prints proxy.py version. Proxy.py not working? Report at: diff --git a/plugin_examples.py b/plugin_examples.py index d7029ec5f5..5a1f123e1c 100644 --- a/plugin_examples.py +++ b/plugin_examples.py @@ -81,14 +81,16 @@ def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[prox assert request.path if request.path in self.REST_API_SPEC: self.client.queue(proxy.build_http_response( - 200, reason=b'OK', + proxy.httpStatusCodes.OK, + reason=b'OK', headers={b'Content-Type': b'application/json'}, body=proxy.bytes_(json.dumps( self.REST_API_SPEC[request.path])) )) else: self.client.queue(proxy.build_http_response( - 404, reason=b'NOT FOUND', body=b'Not Found' + proxy.httpStatusCodes.NOT_FOUND, + reason=b'NOT FOUND', body=b'Not Found' )) return None @@ -134,7 +136,7 @@ class FilterByUpstreamHostPlugin(proxy.HttpProxyBasePlugin): def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: if request.host in self.FILTERED_DOMAINS: raise proxy.HttpRequestRejected( - status_code=418, reason=b'I\'m a tea pot') + status_code=proxy.httpStatusCodes.I_AM_A_TEAPOT, reason=b'I\'m a tea pot') return request def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: @@ -194,7 +196,8 @@ def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.Htt def handle_upstream_chunk(self, chunk: bytes) -> bytes: return proxy.build_http_response( - 200, reason=b'OK', body=b'Hello from man in the middle') + proxy.httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') def on_upstream_connection_close(self) -> None: pass @@ -212,9 +215,11 @@ def routes(self) -> List[Tuple[int, bytes]]: def handle_request(self, request: proxy.HttpParser) -> None: if request.path == b'/http-route-example': - self.client.queue(proxy.build_http_response(200, body=b'HTTP route response')) + self.client.queue(proxy.build_http_response( + proxy.httpStatusCodes.OK, body=b'HTTP route response')) elif request.path == b'/https-route-example': - self.client.queue(proxy.build_http_response(200, body=b'HTTPS route response')) + self.client.queue(proxy.build_http_response( + proxy.httpStatusCodes.OK, body=b'HTTPS route response')) def on_websocket_open(self) -> None: proxy.logger.info('Websocket open') diff --git a/proxy.py b/proxy.py index f9ec00e043..fc2064c5b7 100755 --- a/proxy.py +++ b/proxy.py @@ -50,7 +50,7 @@ PROXY_PY_DIR = os.path.dirname(os.path.realpath(__file__)) PROXY_PY_START_TIME = time.time() -VERSION = (1, 1, 2) +VERSION = (1, 2, 0) __version__ = '.'.join(map(str, VERSION[0:3])) __description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' __author__ = 'Abhinav Singh' @@ -62,35 +62,36 @@ # Defaults DEFAULT_BACKLOG = 100 DEFAULT_BASIC_AUTH = None -DEFAULT_CA_KEY_FILE = None +DEFAULT_BUFFER_SIZE = 1024 * 1024 DEFAULT_CA_CERT_DIR = None DEFAULT_CA_CERT_FILE = None +DEFAULT_CA_KEY_FILE = None DEFAULT_CA_SIGNING_KEY_FILE = None DEFAULT_CERT_FILE = None -DEFAULT_BUFFER_SIZE = 1024 * 1024 DEFAULT_CLIENT_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE -DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE +DEFAULT_DEVTOOLS_WS_PATH = b'/devtools' DEFAULT_DISABLE_HEADERS: List[bytes] = [] -DEFAULT_IPV4_HOSTNAME = ipaddress.IPv4Address('127.0.0.1') -DEFAULT_IPV6_HOSTNAME = ipaddress.IPv6Address('::1') -DEFAULT_KEY_FILE = None -DEFAULT_PORT = 8899 DEFAULT_DISABLE_HTTP_PROXY = False DEFAULT_ENABLE_DEVTOOLS = False -DEFAULT_DEVTOOLS_WS_PATH = b'/devtools' DEFAULT_ENABLE_STATIC_SERVER = False DEFAULT_ENABLE_WEB_SERVER = False +DEFAULT_IPV4_HOSTNAME = ipaddress.IPv4Address('127.0.0.1') +DEFAULT_IPV6_HOSTNAME = ipaddress.IPv6Address('::1') +DEFAULT_KEY_FILE = None +DEFAULT_LOG_FILE = None +DEFAULT_LOG_FORMAT = '%(asctime)s - pid:%(process)d [%(levelname)-.1s] %(funcName)s:%(lineno)d - %(message)s' DEFAULT_LOG_LEVEL = 'INFO' +DEFAULT_NUM_WORKERS = 0 DEFAULT_OPEN_FILE_LIMIT = 1024 DEFAULT_PAC_FILE = None DEFAULT_PAC_FILE_URL_PATH = b'/' DEFAULT_PID_FILE = None -DEFAULT_NUM_WORKERS = 0 -DEFAULT_PLUGINS = '' # Comma separated list of plugins +DEFAULT_PLUGINS = '' +DEFAULT_PORT = 8899 +DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE DEFAULT_STATIC_SERVER_DIR = os.path.join(PROXY_PY_DIR, 'public') +DEFAULT_TIMEOUT = 10 DEFAULT_VERSION = False -DEFAULT_LOG_FORMAT = '%(asctime)s - pid:%(process)d [%(levelname)-.1s] %(funcName)s:%(lineno)d - %(message)s' -DEFAULT_LOG_FILE = None UNDER_TEST = False # Set to True if under test logger = logging.getLogger(__name__) @@ -140,6 +141,35 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: ]) chunkParserStates = ChunkParserStates(1, 2, 3) +HttpStatusCodes = NamedTuple('HttpStatusCodes', [ + # 1xx + ('CONTINUE', int), + ('SWITCHING_PROTOCOLS', int), + # 2xx + ('OK', int), + # 4xx + ('BAD_REQUEST', int), + ('UNAUTHORIZED', int), + ('FORBIDDEN', int), + ('NOT_FOUND', int), + ('PROXY_AUTH_REQUIRED', int), + ('REQUEST_TIMEOUT', int), + ('I_AM_A_TEAPOT', int), + # 5xx + ('INTERNAL_SERVER_ERROR', int), + ('NOT_IMPLEMENTED', int), + ('BAD_GATEWAY', int), + ('GATEWAY_TIMEOUT', int), + ('NETWORK_READ_TIMEOUT_ERROR', int), + ('NETWORK_CONNECT_TIMEOUT_ERROR', int), +]) +httpStatusCodes = HttpStatusCodes( + 100, 101, + 200, + 400, 401, 403, 404, 407, 408, 418, + 500, 501, 502, 504, 598, 599 +) + HttpMethods = NamedTuple('HttpMethods', [ ('GET', bytes), ('HEAD', bytes), @@ -893,7 +923,8 @@ class ProxyConnectionFailed(ProtocolException): """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server.""" RESPONSE_PKT = build_http_response( - 502, reason=b'Bad Gateway', + httpStatusCodes.BAD_GATEWAY, + reason=b'Bad Gateway', headers={ PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, b'Connection': b'close' @@ -915,7 +946,8 @@ class ProxyAuthenticationFailed(ProtocolException): incoming request doesn't present necessary credentials.""" RESPONSE_PKT = build_http_response( - 407, reason=b'Proxy Authentication Required', + httpStatusCodes.PROXY_AUTH_REQUIRED, + reason=b'Proxy Authentication Required', headers={ PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, b'Proxy-Authenticate': b'Basic', @@ -965,7 +997,9 @@ def __init__( static_server_dir: str = DEFAULT_STATIC_SERVER_DIR, enable_static_server: bool = DEFAULT_ENABLE_STATIC_SERVER, devtools_event_queue: Optional[DevtoolsEventQueueType] = None, - devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH) -> None: + devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH, + timeout: int = DEFAULT_TIMEOUT) -> None: + self.timeout = timeout self.auth_code = auth_code self.server_recvbuf_size = server_recvbuf_size self.client_recvbuf_size = client_recvbuf_size @@ -1130,7 +1164,7 @@ def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: Raise HttpRequestRejected or ProtocolException directly to teardown the connection with client. """ - return request + return request # pragma: no cover @abstractmethod def handle_upstream_chunk(self, chunk: bytes) -> bytes: @@ -1150,7 +1184,8 @@ class HttpProxyPlugin(ProtocolHandlerPlugin): """ProtocolHandler plugin which implements HttpProxy specifications.""" PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = build_http_response( - 200, reason=b'Connection established' + httpStatusCodes.OK, + reason=b'Connection established' ) # Used to synchronize with other HttpProxyPlugin instances while @@ -1207,7 +1242,6 @@ def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: ) and self.server and not self.server.closed and self.server.connection in r: logger.debug('Server is ready for reads, reading') raw = self.server.recv(self.config.server_recvbuf_size) - # self.last_activity = ProtocolHandler.now() if not raw: logger.debug('Server closed connection, tearing down...') return True @@ -1393,6 +1427,9 @@ def on_request_complete(self) -> Union[socket.socket, bool]: self.client.connection.setblocking(False) logger.info( 'TLS interception using %s', generated_cert) + # Update all plugin connection reference + for plugin in self.plugins.values(): + plugin.client._conn = self.client.connection return self.client.connection elif self.server: # - proxy-connection header is a mistake, it doesn't seem to be @@ -1859,13 +1896,15 @@ class HttpWebServerPlugin(ProtocolHandlerPlugin): """ProtocolHandler plugin which handles incoming requests to local web server.""" DEFAULT_404_RESPONSE = build_http_response( - 404, reason=b'NOT FOUND', + httpStatusCodes.NOT_FOUND, + reason=b'NOT FOUND', headers={b'Server': PROXY_AGENT_HEADER_VALUE, b'Connection': b'close'} ) DEFAULT_501_RESPONSE = build_http_response( - 501, reason=b'NOT IMPLEMENTED', + httpStatusCodes.NOT_IMPLEMENTED, + reason=b'NOT IMPLEMENTED', headers={b'Server': PROXY_AGENT_HEADER_VALUE, b'Connection': b'close'} ) @@ -1900,10 +1939,12 @@ def serve_file_or_404(self, path: str) -> bool: if content_type is None: content_type = 'text/plain' self.client.queue(build_http_response( - 200, reason=b'OK', headers={ + httpStatusCodes.OK, + reason=b'OK', + headers={ b'Content-Type': bytes_(content_type), - }, body=content - )) + }, + body=content)) return False except IOError: self.client.queue(self.DEFAULT_404_RESPONSE) @@ -2087,12 +2128,12 @@ def connection_inactive_for(self) -> int: return (self.now() - self.last_activity).seconds def is_connection_inactive(self) -> bool: - # TODO: Add input argument option for timeout - return self.connection_inactive_for() > 30 + return self.connection_inactive_for() > self.config.timeout def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool: if self.client.buffer_size() > 0 and self.client.connection in writables: logger.debug('Client is ready for writes, flushing buffer') + self.last_activity = self.now() # Invoke plugin.on_response_chunk chunk = self.client.buffer @@ -2112,8 +2153,9 @@ def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool: def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: if self.client.connection in readables: logger.debug('Client is ready for reads, reading') - client_data = self.client.recv(self.config.client_recvbuf_size) self.last_activity = self.now() + + client_data = self.client.recv(self.config.client_recvbuf_size) if not client_data: logger.debug('Client closed connection, tearing down...') self.client.closed = True @@ -2209,12 +2251,19 @@ def handle_events(self, readables: List[Union[int, _HasFileno]], writables: List return True # Teardown if client buffer is empty and connection is inactive - if self.client.buffer_size() == 0: - if self.is_connection_inactive(): - logger.debug( - 'Client buffer is empty and maximum inactivity has reached ' - 'between client and server connection, tearing down...') - return True + if not self.client.has_buffer() and \ + self.is_connection_inactive(): + self.client.queue(build_http_response( + httpStatusCodes.REQUEST_TIMEOUT, reason=b'Request Timeout', + headers={ + b'Server': PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close', + } + )) + logger.debug( + 'Client buffer is empty and maximum inactivity has reached ' + 'between client and server connection, tearing down...') + return True return False @@ -2241,7 +2290,6 @@ def run_once(self) -> bool: if teardown: return True - return False def flush(self) -> None: @@ -2703,6 +2751,14 @@ def init_parser() -> argparse.ArgumentParser: 'This option is only applicable when static server is also enabled. ' 'See --enable-static-server.' ) + parser.add_argument( + '--timeout', + type=int, + default=DEFAULT_TIMEOUT, + help='Default: ' + str(DEFAULT_TIMEOUT) + '. Number of seconds after which ' + 'an inactive connection must be dropped. Inactivity is defined by no ' + 'data sent or received by the client.' + ) parser.add_argument( '--version', '-v', @@ -2783,7 +2839,8 @@ def main(input_args: List[str]) -> None: static_server_dir=args.static_server_dir, enable_static_server=args.enable_static_server, devtools_event_queue=devtools_event_queue, - devtools_ws_path=args.devtools_ws_path) + devtools_ws_path=args.devtools_ws_path, + timeout=args.timeout) config.plugins = load_plugins( bytes_( diff --git a/tests.py b/tests.py index 015c4f3821..e009f3da3f 100644 --- a/tests.py +++ b/tests.py @@ -1662,7 +1662,7 @@ def mock_connection() -> Any: data=None), selectors.EVENT_READ)], ] self.proxy.run_once() - # Assert our mocked plugin invocations + # Assert our mocked plugins invocations self.plugin.return_value.get_descriptors.assert_called() self.plugin.return_value.write_to_descriptors.assert_called_with([]) self.plugin.return_value.on_client_data.assert_called_with(connect_request) @@ -1698,8 +1698,7 @@ def mock_connection() -> Any: # Assert connection references for all other plugins is updated self.assertEqual(self.plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) - # Currently proxy doesn't update it's own plugin connection reference - # self.assertEqual(self.proxy_plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) + self.assertEqual(self.proxy_plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) class TestHttpRequestRejected(unittest.TestCase): @@ -1761,6 +1760,7 @@ def mock_default_args(mock_args: mock.Mock) -> None: mock_args.enable_devtools = proxy.DEFAULT_ENABLE_DEVTOOLS mock_args.devtools_event_queue = None mock_args.devtools_ws_path = proxy.DEFAULT_DEVTOOLS_WS_PATH + mock_args.timeout = proxy.DEFAULT_TIMEOUT @mock.patch('time.sleep') @mock.patch('proxy.load_plugins') @@ -1817,6 +1817,7 @@ def test_init_with_no_arguments( enable_static_server=mock_args.enable_static_server, devtools_event_queue=None, devtools_ws_path=proxy.DEFAULT_DEVTOOLS_WS_PATH, + timeout=proxy.DEFAULT_TIMEOUT ) mock_acceptor_pool.assert_called_with( From 9d46cba1e8660bf8b0dc41bc07c926933fc1439f Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 13 Oct 2019 03:03:05 -0700 Subject: [PATCH 008/107] Test plugin examples (#130) * Add tests for plugin_examples.* to ensure we never break functionality * Add tests for plugin_examples.* * Test man in the middle * Lint fixes * Checkin * Add tests for plugin examples with TLS encryption enabled --- Makefile | 2 +- plugin_examples.py | 26 ++- proxy.py | 24 ++- tests.py | 425 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 456 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 5dc56f9406..bb44f08a1d 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ release: package twine upload dist/* coverage: - coverage3 run --source=proxy tests.py + coverage3 run --source=proxy,plugin_examples tests.py coverage3 html open htmlcov/index.html diff --git a/plugin_examples.py b/plugin_examples.py index 5a1f123e1c..7ae2d73d31 100644 --- a/plugin_examples.py +++ b/plugin_examples.py @@ -49,7 +49,12 @@ class ProposedRestApiPlugin(proxy.HttpProxyBasePlugin): Used to test and develop client side applications without need of an actual upstream REST API server. - Returns proposed REST API mock responses to the client.""" + Returns proposed REST API mock responses to the client + without establishing upstream connection. + + Note: This plugin won't work if your client is making + HTTPS connection to api.example.com. + """ API_SERVER = b'api.example.com' @@ -76,6 +81,11 @@ class ProposedRestApiPlugin(proxy.HttpProxyBasePlugin): } def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: + # Return None to disable establishing connection to upstream + # Most likely our api.example.com won't even exist under development scenario + return None + + def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: if request.host != self.API_SERVER: return request assert request.path @@ -94,9 +104,6 @@ def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[prox )) return None - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - def handle_upstream_chunk(self, chunk: bytes) -> bytes: return chunk @@ -107,15 +114,16 @@ def on_upstream_connection_close(self) -> None: class RedirectToCustomServerPlugin(proxy.HttpProxyBasePlugin): """Modifies client request to redirect all incoming requests to a fixed server address.""" - UPSTREAM_SERVER = b'http://localhost:8899' + UPSTREAM_SERVER = b'http://localhost:8899/' def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: # Redirect all non-https requests to inbuilt WebServer. if request.method != proxy.httpMethods.CONNECT: - request.url = urlparse.urlsplit(self.UPSTREAM_SERVER) - # This command will re-parse modified url and - # update host, port, path fields - request.set_line_attributes() + request.set_url(self.UPSTREAM_SERVER) + # Update Host header too, otherwise upstream can reject our request + if request.has_header(b'Host'): + request.del_header(b'Host') + request.add_header(b'Host', urlparse.urlsplit(self.UPSTREAM_SERVER).netloc) return request def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: diff --git a/proxy.py b/proxy.py index fc2064c5b7..b0e2a05a13 100755 --- a/proxy.py +++ b/proxy.py @@ -598,6 +598,10 @@ def del_headers(self, headers: List[bytes]) -> None: for key in headers: self.del_header(key.lower()) + def set_url(self, url: bytes) -> None: + self.url = urlparse.urlsplit(url) + self.set_line_attributes() + def set_line_attributes(self) -> None: if self.type == httpParserTypes.REQUEST_PARSER: if self.method == httpMethods.CONNECT and self.url: @@ -700,13 +704,12 @@ def process_line(self, raw: bytes) -> None: line = raw.split(WHITESPACE) if self.type == httpParserTypes.REQUEST_PARSER: self.method = line[0].upper() - self.url = urlparse.urlsplit(line[1]) + self.set_url(line[1]) self.version = line[2] else: self.version = line[0] self.code = line[1] self.reason = WHITESPACE.join(line[2:]) - self.set_line_attributes() def process_header(self, raw: bytes) -> None: parts = raw.split(COLON) @@ -1226,7 +1229,7 @@ def get_descriptors( def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: if self.request.has_upstream_server() and \ self.server and not self.server.closed and \ - self.server.buffer_size() > 0 and \ + self.server.has_buffer() and \ self.server.connection in w: logger.debug('Server is write ready, flushing buffer') try: @@ -1378,16 +1381,20 @@ def on_request_complete(self) -> Union[socket.socket, bool]: if not self.request.has_upstream_server(): return False + self.authenticate() + # Note: can raise HttpRequestRejected exception # Invoke plugin.before_upstream_connection + do_connect = True for plugin in self.plugins.values(): r = plugin.before_upstream_connection(self.request) if r is None: - return False + do_connect = False + break self.request = r - self.authenticate() - self.connect_upstream() + if do_connect: + self.connect_upstream() for plugin in self.plugins.values(): assert self.request is not None @@ -1444,7 +1451,7 @@ def on_request_complete(self) -> Union[socket.socket, bool]: # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection # connection headers are meant for communication between client and # first intercepting proxy. - self.request.add_headers([(b'Via', b'1.1 proxy.py v%s' % version)]) + self.request.add_headers([(b'Via', b'1.1 %s' % PROXY_AGENT_HEADER_VALUE)]) # Disable args.disable_headers before dispatching to upstream self.server.queue( self.request.build( @@ -2095,7 +2102,8 @@ def initialize(self) -> None: """Optionally upgrades connection to HTTPS, set conn in non-blocking mode and initializes plugins.""" conn = self.optionally_wrap_socket(self.client.connection) conn.setblocking(False) - self.client = TcpClientConnection(conn=conn, addr=self.addr) + if self.config.encryption_enabled(): + self.client = TcpClientConnection(conn=conn, addr=self.addr) if b'ProtocolHandlerPlugin' in self.config.plugins: for klass in self.config.plugins[b'ProtocolHandlerPlugin']: instance = klass(self.config, self.client, self.request) diff --git a/tests.py b/tests.py index e009f3da3f..8111f0f718 100644 --- a/tests.py +++ b/tests.py @@ -10,6 +10,7 @@ import base64 import errno import ipaddress +import json import logging import multiprocessing import os @@ -20,9 +21,11 @@ import unittest import uuid from contextlib import closing -from typing import Dict, Optional, Tuple, Union, Any, cast +from typing import Dict, Optional, Tuple, Union, Any, cast, Type from unittest import mock +from urllib import parse as urlparse +import plugin_examples import proxy if os.name != 'nt': @@ -44,6 +47,23 @@ def get_available_port() -> int: return int(port) +def get_plugin_by_test_name(test_name: str) -> Type[proxy.HttpProxyBasePlugin]: + plugin: Type[proxy.HttpProxyBasePlugin] = plugin_examples.ModifyPostDataPlugin + if test_name == 'test_modify_post_data_plugin': + plugin = plugin_examples.ModifyPostDataPlugin + elif test_name == 'test_proposed_rest_api_plugin': + plugin = plugin_examples.ProposedRestApiPlugin + elif test_name == 'test_redirect_to_custom_server_plugin': + plugin = plugin_examples.RedirectToCustomServerPlugin + elif test_name == 'test_filter_by_upstream_host_plugin': + plugin = plugin_examples.FilterByUpstreamHostPlugin + elif test_name == 'test_cache_responses_plugin': + plugin = plugin_examples.CacheResponsesPlugin + elif test_name == 'test_man_in_the_middle_plugin': + plugin = plugin_examples.ManInTheMiddlePlugin + return plugin + + class TestTextBytes(unittest.TestCase): def test_text(self) -> None: @@ -1048,8 +1068,11 @@ def assert_tunnel_response( def test_http_tunnel(self, mock_server_connection: mock.Mock) -> None: server = mock_server_connection.return_value server.connect.return_value = True - server.buffer_size.return_value = 0 - server.has_buffer.side_effect = [False, False, False, True] + + def has_buffer() -> bool: + return cast(bool, server.queue.called) + + server.has_buffer.side_effect = has_buffer self.mock_selector.return_value.select.side_effect = [ [(selectors.SelectorKey( fileobj=self._conn, @@ -1570,6 +1593,233 @@ def test_proxy_plugin_before_upstream_connection_can_teardown( mock_server_conn.assert_not_called() +class TestHttpProxyPluginExamples(unittest.TestCase): + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.config = proxy.ProtocolConfig() + self.plugin = mock.MagicMock() + + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + + plugin = get_plugin_by_test_name(self._testMethodName) + + self.config.plugins = { + b'ProtocolHandlerPlugin': [proxy.HttpProxyPlugin], + b'HttpProxyBasePlugin': [plugin], + } + self._conn = mock_fromfd.return_value + self.proxy = proxy.ProtocolHandler( + self.fileno, self._addr, config=self.config) + self.proxy.initialize() + + @mock.patch('proxy.TcpServerConnection') + def test_modify_post_data_plugin(self, mock_server_conn: mock.Mock) -> None: + original = b'{"key": "value"}' + modified = b'{"key": "modified"}' + + self._conn.recv.return_value = proxy.build_http_request( + b'POST', b'http://httpbin.org/post', + headers={ + b'Host': b'httpbin.org', + b'Content-Type': b'application/x-www-form-urlencoded', + b'Content-Length': proxy.bytes_(len(original)), + }, + body=original + ) + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + self.proxy.run_once() + mock_server_conn.assert_called_with('httpbin.org', 80) + mock_server_conn.return_value.queue.assert_called_with( + proxy.build_http_request( + b'POST', b'/post', + headers={ + b'Host': b'httpbin.org', + b'Content-Length': proxy.bytes_(len(modified)), + b'Content-Type': b'application/json', + b'Via': b'1.1 %s' % proxy.PROXY_AGENT_HEADER_VALUE, + }, + body=modified + ) + ) + + @mock.patch('proxy.TcpServerConnection') + def test_proposed_rest_api_plugin( + self, mock_server_conn: mock.Mock) -> None: + path = b'/v1/users/' + self._conn.recv.return_value = proxy.build_http_request( + b'GET', b'http://%s%s' % (plugin_examples.ProposedRestApiPlugin.API_SERVER, path), + headers={ + b'Host': plugin_examples.ProposedRestApiPlugin.API_SERVER, + } + ) + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.proxy.run_once() + + mock_server_conn.assert_not_called() + self.assertEqual( + self.proxy.client.buffer, + proxy.build_http_response( + proxy.httpStatusCodes.OK, reason=b'OK', + headers={b'Content-Type': b'application/json'}, + body=proxy.bytes_(json.dumps(plugin_examples.ProposedRestApiPlugin.REST_API_SPEC[path])) + )) + + @mock.patch('proxy.TcpServerConnection') + def test_redirect_to_custom_server_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = proxy.build_http_request( + b'GET', b'http://example.org/get', + headers={ + b'Host': b'example.org', + } + ) + self._conn.recv.return_value = request + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.proxy.run_once() + + upstream = urlparse.urlsplit( + plugin_examples.RedirectToCustomServerPlugin.UPSTREAM_SERVER) + mock_server_conn.assert_called_with('localhost', 8899) + mock_server_conn.return_value.queue.assert_called_with( + proxy.build_http_request( + b'GET', upstream.path, + headers={ + b'Host': upstream.netloc, + b'Via': b'1.1 %s' % proxy.PROXY_AGENT_HEADER_VALUE, + } + ) + ) + + @mock.patch('proxy.TcpServerConnection') + def test_filter_by_upstream_host_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = proxy.build_http_request( + b'GET', b'http://google.com/', + headers={ + b'Host': b'google.com', + } + ) + self._conn.recv.return_value = request + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.proxy.run_once() + + mock_server_conn.assert_not_called() + self.assertEqual( + self.proxy.client.buffer, + proxy.build_http_response( + proxy.httpStatusCodes.I_AM_A_TEAPOT, + reason=b'I\'m a tea pot', + headers={ + proxy.PROXY_AGENT_HEADER_KEY: proxy.PROXY_AGENT_HEADER_VALUE + }, + ) + ) + + @mock.patch('proxy.TcpServerConnection') + def test_cache_responses_plugin( + self, mock_server_conn: mock.Mock) -> None: + pass + + @mock.patch('proxy.TcpServerConnection') + def test_man_in_the_middle_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = proxy.build_http_request( + b'GET', b'http://super.secure/', + headers={ + b'Host': b'super.secure', + } + ) + self._conn.recv.return_value = request + + server = mock_server_conn.return_value + server.connect.return_value = True + + def has_buffer() -> bool: + return cast(bool, server.queue.called) + + def closed() -> bool: + return not server.connect.called + + server.has_buffer.side_effect = has_buffer + type(server).closed = mock.PropertyMock(side_effect=closed) + + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=server.connection, + fd=server.connection.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], + [(selectors.SelectorKey( + fileobj=server.connection, + fd=server.connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + # Client read + self.proxy.run_once() + mock_server_conn.assert_called_with('super.secure', 80) + server.connect.assert_called_once() + queued_request = \ + proxy.build_http_request( + b'GET', b'/', + headers={ + b'Host': b'super.secure', + b'Via': b'1.1 %s' % proxy.PROXY_AGENT_HEADER_VALUE + } + ) + server.queue.assert_called_once_with(queued_request) + + # Server write + self.proxy.run_once() + server.flush.assert_called_once() + + # Server read + server.recv.return_value = \ + proxy.build_http_response( + proxy.httpStatusCodes.OK, + reason=b'OK', body=b'Original Response From Upstream') + self.proxy.run_once() + self.assertEqual( + self.proxy.client.buffer, + proxy.build_http_response( + proxy.httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') + ) + + class TestHttpProxyTlsInterception(unittest.TestCase): @mock.patch('ssl.wrap_socket') @@ -1701,6 +1951,175 @@ def mock_connection() -> Any: self.assertEqual(self.proxy_plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) +class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): + + @mock.patch('ssl.wrap_socket') + @mock.patch('ssl.create_default_context') + @mock.patch('proxy.TcpServerConnection') + @mock.patch('subprocess.Popen') + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_popen: mock.Mock, + mock_server_conn: mock.Mock, + mock_ssl_context: mock.Mock, + mock_ssl_wrap: mock.Mock) -> None: + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + self.mock_popen = mock_popen + self.mock_server_conn = mock_server_conn + self.mock_ssl_context = mock_ssl_context + self.mock_ssl_wrap = mock_ssl_wrap + + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.config = proxy.ProtocolConfig( + ca_cert_file='ca-cert.pem', + ca_key_file='ca-key.pem', + ca_signing_key_file='ca-signing-key.pem',) + self.plugin = mock.MagicMock() + + plugin = get_plugin_by_test_name(self._testMethodName) + + self.config.plugins = { + b'ProtocolHandlerPlugin': [proxy.HttpProxyPlugin], + b'HttpProxyBasePlugin': [plugin], + } + self._conn = mock.MagicMock(spec=socket.socket) + mock_fromfd.return_value = self._conn + self.proxy = proxy.ProtocolHandler( + self.fileno, self._addr, config=self.config) + self.proxy.initialize() + + self.server = self.mock_server_conn.return_value + + self.server_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_context.return_value.wrap_socket.return_value = self.server_ssl_connection + self.client_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_wrap.return_value = self.client_ssl_connection + + def has_buffer() -> bool: + return cast(bool, self.server.queue.called) + + def closed() -> bool: + return not self.server.connect.called + + def mock_connection() -> Any: + if self.mock_ssl_context.return_value.wrap_socket.called: + return self.server_ssl_connection + return self._conn + + self.server.has_buffer.side_effect = has_buffer + type(self.server).closed = mock.PropertyMock(side_effect=closed) + type(self.server).connection = mock.PropertyMock(side_effect=mock_connection) + + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self.client_ssl_connection, + fd=self.client_ssl_connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self.server_ssl_connection, + fd=self.server_ssl_connection.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], + [(selectors.SelectorKey( + fileobj=self.server_ssl_connection, + fd=self.server_ssl_connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + # Connect + def send(raw: bytes) -> int: + return len(raw) + + self._conn.send.side_effect = send + self._conn.recv.return_value = proxy.build_http_request( + proxy.httpMethods.CONNECT, b'uni.corn:443' + ) + self.proxy.run_once() + + self.mock_popen.assert_called() + self.mock_server_conn.assert_called_once_with('uni.corn', 443) + self.server.connect.assert_called() + self.assertEqual(self.proxy.client.connection, self.client_ssl_connection) + self.assertEqual(self.server.connection, self.server_ssl_connection) + self._conn.send.assert_called_with( + proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT + ) + self.assertEqual(self.proxy.client.buffer, b'') + + def test_modify_post_data_plugin(self) -> None: + original = b'{"key": "value"}' + modified = b'{"key": "modified"}' + self.client_ssl_connection.recv.return_value = proxy.build_http_request( + b'POST', b'/', + headers={ + b'Host': b'uni.corn', + b'Content-Type': b'application/x-www-form-urlencoded', + b'Content-Length': proxy.bytes_(len(original)), + }, + body=original + ) + self.proxy.run_once() + self.server.queue.assert_called_with( + proxy.build_http_request( + b'POST', b'/', + headers={ + b'Host': b'uni.corn', + b'Content-Length': proxy.bytes_(len(modified)), + b'Content-Type': b'application/json', + }, + body=modified + ) + ) + + @mock.patch('proxy.TcpServerConnection') + def test_cache_responses_plugin( + self, mock_server_conn: mock.Mock) -> None: + pass + + @mock.patch('proxy.TcpServerConnection') + def test_man_in_the_middle_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = proxy.build_http_request( + b'GET', b'/', + headers={ + b'Host': b'uni.corn', + } + ) + self.client_ssl_connection.recv.return_value = request + + # Client read + self.proxy.run_once() + self.server.queue.assert_called_once_with(request) + + # Server write + self.proxy.run_once() + self.server.flush.assert_called_once() + + # Server read + self.server.recv.return_value = \ + proxy.build_http_response( + proxy.httpStatusCodes.OK, + reason=b'OK', body=b'Original Response From Upstream') + self.proxy.run_once() + self.assertEqual( + self.proxy.client.buffer, + proxy.build_http_response( + proxy.httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') + ) + + class TestHttpRequestRejected(unittest.TestCase): def setUp(self) -> None: From a1bb659488603aae67e43aa5de932284a690ac00 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 15 Oct 2019 23:56:39 -0700 Subject: [PATCH 009/107] Threadless execution using coroutines (#134) * Workers need not register/unregister sock for every loop * No need of explicit socket.settimeout(0) which is same as socket.setblocking(False) * Remove settimeout assertion * Only store sender side of Pipe(). Also ensure both end of the Pipe() are closed on shutdown * Make now global. Also we seem to be using datetime.utcnow and time.time for similar purposes * Use time.time throughout. Remove incomplete test_cache_responses_plugin to avoid resource leak in tests * Remove unused * Wrap selector register/unregister within a context manager * Refactor in preparation of threadless request handling * MyPy generator fix * Add --threadless flag * Internally call them acceptors * Internally use acceptors * Add Threadless class. Also no need to pass family over pipe to acceptors. * Make threadless work for a single client :) * Threadless is soon be our default * Close client queue * Use context manager for register/unregister * Fix Acceptor tests broken after refactoring * Use asyncio tasks to invoke ProtocolHandle.handle_events This gives all client threads a chance to respond without waiting for other handlers to return. * Explicitly initialize event loop per Threadless process * Mypy fixes * Add ThreadlessWork abstract class implemented by ProtocolHandler * Add benchmark.py Avoid TIME_WAIT by properly shutting down the connection. * Add benchmark.py as part of testing workflow * When e2e encryption is enabled, unwrap socket before shutdown to ensure CLOSED state * MyPy fixes, Union should have worked, but likely unwrap is not part of socket.socket hence * Unwrap if wrapped before shutdown * Unwrap if wrapped before shutdown * socket.SHUT_RDWR will cause leaks * MyPy * Add instructions for monitor.sh * Avoid recursive exception in new_socket_connection and only invoke plugins/shutdown if server connection was initialized * Add Fast & Scalable section * Update internal classes section * Dont print out local dir path in help text :) * Refactor * Fix a bug where response parser for HTTP only requests was reused for pipelined requests resulting in a hang * Add chrome_with_proxy.sh helper script * Handle OSError during client.flush which can happen due to invalid protocol type for socket error * Remove redundant e * Add classmethods to quickly construct a parser object * Don't raise from TcpConnection abstract class. This allows both client/socket side of communication to handle exceptions as necessary. We might refactor this again later to remove redundant code :) * Disable response parsing when TLS interception is enabled. See issue #127 * remove unused imports * Within webserver parse pipelined requests only if we have a route * Add ShortLinkPlugin plugin * Add more shortlinks * Add ShortLinkPlugin to README.md * Add path forwarding too instead of leaving as excercise ;) * Add shortlink to TOC * Ensure no socket leaks * Ensure no leaks * Naming * Default number of clients 1 * Avoid shortlinking localhost * Stress more --- .github/workflows/testing.yml | 4 +- .gitignore | 1 - Makefile | 4 +- README.md | 142 ++++-- benchmark.py | 95 ++++ chrome_with_proxy.sh | 24 + monitor_open_files.sh | 33 ++ plugin_examples.py | 66 +++ proxy.py | 849 ++++++++++++++++++++++++---------- tests.py | 155 ++++--- 10 files changed, 1014 insertions(+), 359 deletions(-) create mode 100755 benchmark.py create mode 100755 chrome_with_proxy.sh create mode 100755 monitor_open_files.sh diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 7815f0afc8..11cca3215a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -27,8 +27,8 @@ jobs: run: | # The GitHub editor is 127 chars wide # W504 screams for line break after binary operators - flake8 --ignore=W504 --max-line-length=127 proxy.py plugin_examples.py tests.py setup.py + flake8 --ignore=W504 --max-line-length=127 proxy.py plugin_examples.py tests.py setup.py benchmark.py # mypy compliance check - mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py setup.py + mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py setup.py benchmark.py - name: Run Tests run: pytest tests.py diff --git a/.gitignore b/.gitignore index 9a8ba1c0db..eaabbb272a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,3 @@ proxy.py.iml *.pyc ca-*.pem https-*.pem -benchmark.py diff --git a/Makefile b/Makefile index bb44f08a1d..981bc25b75 100644 --- a/Makefile +++ b/Makefile @@ -44,8 +44,8 @@ coverage: open htmlcov/index.html lint: - flake8 --ignore=W504 --max-line-length=127 proxy.py plugin_examples.py tests.py setup.py - mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py setup.py + flake8 --ignore=W504 --max-line-length=127 proxy.py plugin_examples.py tests.py setup.py benchmark.py + mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py setup.py benchmark.py autopep8: autopep8 --recursive --in-place --aggressive proxy.py diff --git a/README.md b/README.md index ea57228e7a..3c23e58647 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Table of Contents * [Stable version](#stable-version-from-docker-hub) * [Development version](#build-development-version-locally) * [Plugin Examples](#plugin-examples) + * [ShortLinkPlugin](#shortlinkplugin) * [ModifyPostDataPlugin](#modifypostdataplugin) * [ProposedRestApiPlugin](#proposedrestapiplugin) * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) @@ -49,9 +50,16 @@ Table of Contents * [End-to-End Encryption](#end-to-end-encryption) * [TLS Interception](#tls-interception) * [import proxy.py](#import-proxypy) - * [proxy.new_socket_connection](#proxynew_socket_connection) - * [proxy.socket_connection](#proxysocket_connection) - * [proxy.build_http_request](#proxybuild_http_request) + * [TCP Sockets](#tcp-sockets) + * [proxy.new_socket_connection](#proxynew_socket_connection) + * [proxy.socket_connection](#proxysocket_connection) + * [Http Client](#http-client) + * [proxy.build_http_request](#proxybuild_http_request) + * [proxy.build_http_response](#proxybuild_http_response) + * [Websocket Client](#websocket-client) + * [proxy.WebsocketFrame](#proxywebsocketframe) + * [proxy.WebsocketClient](#proxywebsocketclient) + * [Embed proxy.py](#embed-proxypy) * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) * [Everything is a plugin](#everything-is-a-plugin) * [Internal Architecture](#internal-architecture) @@ -68,6 +76,29 @@ Table of Contents Features ======== +- Fast & Scalable + - Scales by using all available cores on the system + - Threadless executions using coroutine + - Made to handle `tens-of-thousands` connections / sec + ``` + # On Macbook Pro 2015 / 2.8 GHz Intel Core i7 + $ hey -n 10000 -c 100 http://localhost:8899/ + + Summary: + Total: 0.6157 secs + Slowest: 0.1049 secs + Fastest: 0.0007 secs + Average: 0.0055 secs + Requests/sec: 16240.5444 + + Total data: 800000 bytes + Size/request: 80 bytes + + Response time histogram: + 0.001 [1] | + 0.011 [9565] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.022 [332] |■ + ``` - Lightweight - Distributed as a single file module `~100KB` - Uses only `~5-20MB` RAM @@ -204,6 +235,35 @@ See [plugin_examples.py](https://github.com/abhinavsingh/proxy.py/blob/develop/p All the examples below also works with `https` traffic but require additional flags and certificate generation. See [TLS Interception](#tls-interception). +## ShortLinkPlugin + +Add support for short links in your favorite browsers / applications. + +Start `proxy.py` as: + +``` +$ proxy.py \ + --plugins plugin_examples.ShortLinkPlugin +``` + +Now you can speed up your daily browsing experience by visiting your +favorite website using single character domain names :). This works +across all browsers. + +Following short links are enabled by default: + +Short Link | Destination URL +:--------: | :---------------: +a/ | amazon.com +i/ | instagram.com +l/ | linkedin.com +f/ | facebook.com +g/ | google.com +t/ | twitter.com +w/ | web.whatsapp.com +y/ | youtube.com +proxy/ | localhost:8899 + ## ModifyPostDataPlugin Modifies POST request body before sending request to upstream server. @@ -599,7 +659,9 @@ $ python >>> ``` -## proxy.new_socket_connection +## TCP Sockets + +### proxy.new_socket_connection Attempts to create an IPv4 connection, then IPv6 and finally a dual stack connection to provided address. @@ -610,7 +672,7 @@ finally a dual stack connection to provided address. >>> conn.close() ``` -## proxy.socket_connection +### proxy.socket_connection `socket_connection` is a convenient decorator + context manager around `new_socket_connection` which ensures `conn.close` is implicit. @@ -630,9 +692,11 @@ As a decorator: >>> ... [ use connection ] ... ``` -## proxy.build_http_request +## Http Client -#### Generate HTTP GET request +### proxy.build_http_request + +##### Generate HTTP GET request ``` >>> proxy.build_http_request(b'GET', b'/') @@ -640,7 +704,7 @@ b'GET / HTTP/1.1\r\n\r\n' >>> ``` -#### Generate HTTP GET request with headers +##### Generate HTTP GET request with headers ``` >>> proxy.build_http_request(b'GET', b'/', @@ -649,7 +713,7 @@ b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' >>> ``` -#### Generate HTTP POST request with headers and body +##### Generate HTTP POST request with headers and body ``` >>> import json @@ -659,6 +723,22 @@ b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' b'POST /form HTTP/1.1\r\nContent-type: application/json\r\n\r\n{"email": "hello@world.com"}' ``` +### proxy.build_http_response + +TODO + +## Websocket Client + +### proxy.WebsocketFrame + +TODO + +### proxy.WebsocketClient + +TODO + +## Embed proxy.py + To start `proxy.py` server from imported `proxy.py` module, simply do: ``` @@ -710,14 +790,14 @@ mechanism. Its responsibility is to establish connection between client and upstream [TcpServerConnection](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L204-L227) and invoke `HttpProxyBasePlugin` lifecycle hooks. -- `ProtocolHandler` threads are started by [Worker](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L424-L472) +- `ProtocolHandler` threads are started by [Acceptor](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L424-L472) processes. -- `--num-workers` `Worker` processes are started by +- `--num-workers` `Acceptor` processes are started by [AcceptorPool](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L368-L421) on start-up. -- `AcceptorPool` listens on server socket and pass the handler to `Worker` processes. +- `AcceptorPool` listens on server socket and pass the handler to `Acceptor` processes. Workers are responsible for accepting new client connections and starting `ProtocolHandler` thread. @@ -748,33 +828,23 @@ Example: ``` $ pydoc3 proxy -Help on module proxy: - -NAME - proxy - -DESCRIPTION - proxy.py - ~~~~~~~~ - Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. CLASSES abc.ABC(builtins.object) HttpProxyBasePlugin HttpWebServerBasePlugin - DevtoolsFrontendPlugin + DevtoolsWebsocketPlugin HttpWebServerPacFilePlugin ProtocolHandlerPlugin - DevtoolsEventGeneratorPlugin + DevtoolsProtocolPlugin HttpProxyPlugin HttpWebServerPlugin TcpConnection TcpClientConnection TcpServerConnection WebsocketClient + ThreadlessWork + ProtocolHandler(threading.Thread, ThreadlessWork) builtins.Exception(builtins.BaseException) ProtocolException HttpRequestRejected @@ -789,17 +859,20 @@ CLASSES WebsocketFrame builtins.tuple(builtins.object) ChunkParserStates + HttpMethods HttpParserStates HttpParserTypes HttpProtocolTypes + HttpStatusCodes TcpConnectionTypes WebsocketOpcodes contextlib.ContextDecorator(builtins.object) socket_connection multiprocessing.context.Process(multiprocessing.process.BaseProcess) - Worker + Acceptor + Threadless threading.Thread(builtins.object) - ProtocolHandler + ProtocolHandler(threading.Thread, ThreadlessWork) ``` Frequently Asked Questions @@ -905,8 +978,8 @@ usage: proxy.py [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] [--pac-file-url-path PAC_FILE_URL_PATH] [--pid-file PID_FILE] [--plugins PLUGINS] [--port PORT] [--server-recvbuf-size SERVER_RECVBUF_SIZE] - [--static-server-dir STATIC_SERVER_DIR] [--timeout TIMEOUT] - [--version] + [--static-server-dir STATIC_SERVER_DIR] [--threadless] + [--timeout TIMEOUT] [--version] proxy.py v1.2.0 @@ -991,10 +1064,11 @@ optional arguments: value for faster downloads at the expense of increased RAM. --static-server-dir STATIC_SERVER_DIR - Default: /Users/abhinav/Dev/proxy.py/public. Static - server root directory. This option is only applicable - when static server is also enabled. See --enable- - static-server. + Default: "public" folder in directory where proxy.py + is placed. This option is only applicable when static + server is also enabled. See --enable-static-server. + --threadless Default: False. When disabled a new thread is spawned + to handle each client connection. --timeout TIMEOUT Default: 10. Number of seconds after which an inactive connection must be dropped. Inactivity is defined by no data sent or received by the client. diff --git a/benchmark.py b/benchmark.py new file mode 100755 index 0000000000..3f42825015 --- /dev/null +++ b/benchmark.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import argparse +import asyncio +import sys +from typing import List, Tuple + +import proxy + +DEFAULT_N = 1 + + +def init_parser() -> argparse.ArgumentParser: + """Initializes and returns argument parser.""" + parser = argparse.ArgumentParser( + description='Benchmark opens N concurrent connections ' + 'to proxy.py web server. Currently, HTTP/1.1 ' + 'keep-alive connections are opened. Over each opened ' + 'connection multiple pipelined request / response ' + 'packets are exchanged with proxy.py web server.', + epilog='Proxy.py not working? Report at: %s/issues/new' % proxy.__homepage__ + ) + parser.add_argument( + '--n', '-n', + type=int, + default=DEFAULT_N, + help='Default: ' + str(DEFAULT_N) + '. See description above for meaning of N.' + ) + return parser + + +class Benchmark: + + def __init__(self, n: int = DEFAULT_N) -> None: + self.n = n + self.clients: List[Tuple[asyncio.StreamReader, asyncio.StreamWriter]] = [] + + async def open_connections(self) -> None: + for _ in range(self.n): + self.clients.append(await asyncio.open_connection('::', 8899)) + print('Opened ' + str(self.n) + ' connections') + + def send_requests(self) -> None: + for _, writer in self.clients: + writer.write(proxy.build_http_request( + proxy.httpMethods.GET, b'/' + )) + + async def recv_responses(self) -> None: + for reader, _ in self.clients: + response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + while response.state != proxy.httpParserStates.COMPLETE: + response.parse(await reader.read(proxy.DEFAULT_BUFFER_SIZE)) + + async def close_connections(self) -> None: + for reader, writer in self.clients: + writer.close() + await writer.wait_closed() + print('Closed ' + str(self.n) + ' connections') + + async def run(self) -> None: + num_completed_requests_per_connection: int = 0 + try: + await self.open_connections() + print('Exchanging request / response packets') + while True: + self.send_requests() + await self.recv_responses() + num_completed_requests_per_connection += 1 + await asyncio.sleep(0.1) + finally: + await self.close_connections() + print('Exchanged ' + str(num_completed_requests_per_connection) + + ' request / response per connection') + + +def main(input_args: List[str]) -> None: + args = init_parser().parse_args(input_args) + benchmark = Benchmark(n=args.n) + try: + asyncio.run(benchmark.run()) + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main(sys.argv[1:]) # pragma: no cover diff --git a/chrome_with_proxy.sh b/chrome_with_proxy.sh new file mode 100755 index 0000000000..66e00cb87d --- /dev/null +++ b/chrome_with_proxy.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# proxy.py +# ~~~~~~~~ +# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# +# :copyright: (c) 2013-present by Abhinav Singh and contributors. +# :license: BSD, see LICENSE for more details. +# +# Usage +# ./chrome_with_proxy + +PROXY_PY_ADDR=$1 +if [ -z "$PROXY_PY_ADDR" ]; then + PROXY_PY_ADDR="localhost:8899" +fi + +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --no-first-run \ + --no-default-browser-check \ + --user-data-dir="$(mktemp -d -t 'chrome-remote_data_dir')" \ + --proxy-server=$PROXY_PY_ADDR \ + --ignore-urlfetcher-cert-requests \ + --ignore-certificate-errors diff --git a/monitor_open_files.sh b/monitor_open_files.sh new file mode 100755 index 0000000000..a5001d002b --- /dev/null +++ b/monitor_open_files.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# proxy.py +# ~~~~~~~~ +# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# +# :copyright: (c) 2013-present by Abhinav Singh and contributors. +# :license: BSD, see LICENSE for more details. +# +# Usage +# ./monitor +# +# Alternately, just run: +# watch -n 1 'lsof -i TCP:8899 | grep -v LISTEN' + +PROXY_PY_PID=$1 +if [ -z "$PROXY_PY_PID" ]; then + echo "PROXY_PY_PID required as argument." + exit 1 +fi + +OPEN_FILES_BY_MAIN=$(lsof -p "$PROXY_PY_PID" | wc -l) +echo "[$PROXY_PY_PID] Main process: $OPEN_FILES_BY_MAIN" + +pgrep -P "$PROXY_PY_PID" | while read -r acceptorPid; do + OPEN_FILES_BY_ACCEPTOR=$(lsof -p "$acceptorPid" | wc -l) + echo "[$acceptorPid] Acceptor process: $OPEN_FILES_BY_ACCEPTOR" + + pgrep -P "$acceptorPid" | while read -r threadlessPid; do + OPEN_FILES_BY_THREADLESS=$(lsof -p "$threadlessPid" | wc -l) + echo " [$threadlessPid] Threadless process: $OPEN_FILES_BY_THREADLESS" + done +done diff --git a/plugin_examples.py b/plugin_examples.py index 7ae2d73d31..80069649a1 100644 --- a/plugin_examples.py +++ b/plugin_examples.py @@ -14,6 +14,72 @@ from urllib import parse as urlparse import proxy +from proxy import HttpParser + + +class ShortLinkPlugin(proxy.HttpProxyBasePlugin): + """Add support for short links in your favorite browsers / applications. + + Enable ShortLinkPlugin and speed up your daily browsing experience. + + Example: + * f/ for facebook.com + * g/ for google.com + * t/ for twitter.com + * y/ for youtube.com + * proxy/ for proxy.py internal web servers. + Customize map below for your taste and need. + + Paths are also preserved. E.g. t/imoracle will + resolve to http://twitter.com/imoracle. + """ + + SHORT_LINKS = { + b'a': b'amazon.com', + b'i': b'instagram.com', + b'l': b'linkedin.com', + b'f': b'facebook.com', + b'g': b'google.com', + b't': b'twitter.com', + b'w': b'web.whatsapp.com', + b'y': b'youtube.com', + b'proxy': b'localhost:8899', + } + + def before_upstream_connection(self, request: HttpParser) -> Optional[HttpParser]: + if request.host and request.host != b'localhost' and proxy.DOT not in request.host: + # Avoid connecting to upstream + return None + return request + + def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: + if request.host and request.host != b'localhost' and proxy.DOT not in request.host: + if request.host in self.SHORT_LINKS: + path = proxy.SLASH if not request.path else request.path + self.client.queue(proxy.build_http_response( + proxy.httpStatusCodes.SEE_OTHER, reason=b'See Other', + headers={ + b'Location': b'http://' + self.SHORT_LINKS[request.host] + path, + b'Content-Length': b'0', + b'Connection': b'close', + } + )) + else: + self.client.queue(proxy.build_http_response( + proxy.httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', + headers={ + b'Content-Length': b'0', + b'Connection': b'close', + } + )) + return None + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_upstream_connection_close(self) -> None: + pass class ModifyPostDataPlugin(proxy.HttpProxyBasePlugin): diff --git a/proxy.py b/proxy.py index b0e2a05a13..4324505605 100755 --- a/proxy.py +++ b/proxy.py @@ -9,9 +9,9 @@ :license: BSD, see LICENSE for more details. """ import argparse +import asyncio import base64 import contextlib -import datetime import errno import functools import hashlib @@ -39,7 +39,8 @@ from multiprocessing import connection from multiprocessing.reduction import send_handle, recv_handle from types import TracebackType -from typing import Any, Dict, List, Tuple, Optional, Union, NamedTuple, Callable, TYPE_CHECKING, Type, cast +from typing import Any, Dict, List, Tuple, Optional, Union, NamedTuple, Callable, Type, TypeVar +from typing import cast, Generator, TYPE_CHECKING from urllib import parse as urlparse from typing_extensions import Protocol @@ -90,6 +91,7 @@ DEFAULT_PORT = 8899 DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE DEFAULT_STATIC_SERVER_DIR = os.path.join(PROXY_PY_DIR, 'public') +DEFAULT_THREADLESS = False DEFAULT_TIMEOUT = 10 DEFAULT_VERSION = False UNDER_TEST = False # Set to True if under test @@ -122,7 +124,7 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: version = bytes_(__version__) -CRLF, COLON, WHITESPACE, COMMA, DOT, HTTP_1_1 = b'\r\n', b':', b' ', b',', b'.', b'HTTP/1.1' +CRLF, COLON, WHITESPACE, COMMA, DOT, SLASH, HTTP_1_1 = b'\r\n', b':', b' ', b',', b'.', b'/', b'HTTP/1.1' PROXY_AGENT_HEADER_KEY = b'Proxy-agent' PROXY_AGENT_HEADER_VALUE = b'proxy.py v' + version PROXY_AGENT_HEADER = PROXY_AGENT_HEADER_KEY + \ @@ -147,6 +149,11 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: ('SWITCHING_PROTOCOLS', int), # 2xx ('OK', int), + # 3xx + ('MOVED_PERMANENTLY', int), + ('SEE_OTHER', int), + ('TEMPORARY_REDIRECT', int), + ('PERMANENT_REDIRECT', int), # 4xx ('BAD_REQUEST', int), ('UNAUTHORIZED', int), @@ -166,6 +173,7 @@ def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: httpStatusCodes = HttpStatusCodes( 100, 101, 200, + 301, 303, 307, 308, 400, 401, 403, 404, 407, 408, 418, 500, 501, 502, 504, 598, 599 ) @@ -325,6 +333,7 @@ def find_http_line(raw: bytes) -> Tuple[Optional[bytes], bytes]: def new_socket_connection(addr: Tuple[str, int]) -> socket.socket: + conn = None try: ip = ipaddress.ip_address(addr[0]) if ip.version == 4: @@ -336,10 +345,13 @@ def new_socket_connection(addr: Tuple[str, int]) -> socket.socket: socket.AF_INET6, socket.SOCK_STREAM, 0) conn.connect((addr[0], addr[1], 0, 0)) except ValueError: - # Not a valid IP address, most likely its a domain name, - # try to establish dual stack IPv4/IPv6 connection. - conn = socket.create_connection(addr) - return conn + pass # does not appear to be an IPv4 or IPv6 address + + if conn is not None: + return conn + + # try to establish dual stack IPv4/IPv6 connection. + return socket.create_connection(addr) class socket_connection(contextlib.ContextDecorator): @@ -405,21 +417,15 @@ def send(self, data: bytes) -> int: return self.connection.send(data) def recv(self, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Optional[bytes]: - try: - data: bytes = self.connection.recv(buffer_size) - if len(data) > 0: - logger.debug( - 'received %d bytes from %s' % - (len(data), self.tag)) - return data - except socket.error as e: - if e.errno == errno.ECONNRESET: - logger.debug('%r' % e) - else: - logger.exception( - 'Exception while receiving from connection %s %r with reason %r' % - (self.tag, self.connection, e)) - return None + """Users must handle socket.error exceptions""" + data: bytes = self.connection.recv(buffer_size) + if len(data) == 0: + return None + logger.debug( + 'received %d bytes from %s' % + (len(data), self.tag)) + # logger.info(data) + return data def close(self) -> bool: if not self.closed: @@ -438,11 +444,11 @@ def queue(self, data: bytes) -> int: return len(data) def flush(self) -> int: + """Users must handle BrokenPipeError exceptions""" if self.buffer_size() == 0: return 0 - if self.closed: - raise BrokenPipeError() sent: int = self.send(self.buffer) + # logger.info(self.buffer[:sent]) self.buffer = self.buffer[sent:] logger.debug('flushed %d bytes to %s' % (sent, self.tag)) return sent @@ -543,6 +549,9 @@ def to_chunks(raw: bytes, chunk_size: int = DEFAULT_BUFFER_SIZE) -> bytes: return CRLF.join(chunks) + CRLF +T = TypeVar('T', bound='HttpParser') + + class HttpParser: """HTTP request/response parser.""" @@ -575,6 +584,18 @@ def __init__(self, parser_type: int) -> None: self.port: Optional[int] = None self.path: Optional[bytes] = None + @classmethod + def request(cls: Type[T], raw: bytes) -> T: + parser = cls(httpParserTypes.REQUEST_PARSER) + parser.parse(raw) + return parser + + @classmethod + def response(cls: Type[T], raw: bytes) -> T: + parser = cls(httpParserTypes.RESPONSE_PARSER) + parser.parse(raw) + return parser + def header(self, key: bytes) -> bytes: if key.lower() not in self.headers: raise KeyError('%s not found in headers', text_(key)) @@ -765,7 +786,10 @@ def __init__(self, hostname: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], port: int, backlog: int, num_workers: int, - work_klass: type, **kwargs: Any) -> None: + threadless: bool, + work_klass: type, + **kwargs: Any) -> None: + self.threadless = threadless self.running: bool = False self.hostname: Union[ipaddress.IPv4Address, @@ -775,11 +799,9 @@ def __init__(self, self.backlog: int = backlog self.socket: Optional[socket.socket] = None - self.current_worker_id = 0 - self.num_workers = num_workers - self.workers: List[Worker] = [] - self.work_queues: List[Tuple[connection.Connection, - connection.Connection]] = [] + self.num_acceptors = num_workers + self.acceptors: List[Acceptor] = [] + self.work_queues: List[connection.Connection] = [] self.work_klass = work_klass self.kwargs = kwargs @@ -790,26 +812,31 @@ def listen(self) -> None: self.socket.bind((str(self.hostname), self.port)) self.socket.listen(self.backlog) self.socket.setblocking(False) - self.socket.settimeout(0) logger.info('Listening on %s:%d' % (self.hostname, self.port)) def start_workers(self) -> None: """Start worker processes.""" - for worker_id in range(self.num_workers): + for _ in range(self.num_acceptors): work_queue = multiprocessing.Pipe() - - worker = Worker(work_queue[1], self.work_klass, **self.kwargs) - worker.daemon = True - worker.start() - - self.workers.append(worker) - self.work_queues.append(work_queue) - logger.info('Started %d workers' % self.num_workers) + acceptor = Acceptor( + self.family, + self.threadless, + work_queue[1], + self.work_klass, + **self.kwargs + ) + # acceptor.daemon = True + acceptor.start() + self.acceptors.append(acceptor) + self.work_queues.append(work_queue[0]) + logger.info('Started %d workers' % self.num_acceptors) def shutdown(self) -> None: - logger.info('Shutting down %d workers' % self.num_workers) - for worker in self.workers: - worker.join() + logger.info('Shutting down %d workers' % self.num_acceptors) + for acceptor in self.acceptors: + acceptor.join() + for work_queue in self.work_queues: + work_queue.close() def setup(self) -> None: """Listen on port, setup workers and pass server socket to workers.""" @@ -817,16 +844,181 @@ def setup(self) -> None: self.listen() self.start_workers() - # Send server socket to workers. + # Send server socket to all acceptor processes. assert self.socket is not None - for work_queue in self.work_queues: - work_queue[0].send(self.family) - send_handle(work_queue[0], self.socket.fileno(), - self.workers[self.current_worker_id].pid) + for index in range(self.num_acceptors): + send_handle( + self.work_queues[index], + self.socket.fileno(), + self.acceptors[index].pid + ) self.socket.close() -class Worker(multiprocessing.Process): +class ThreadlessWork(ABC): + """Implement ThreadlessWork to hook into the event loop provided by Threadless process.""" + + @abstractmethod + def initialize(self) -> None: + pass # pragma: no cover + + @abstractmethod + def is_inactive(self) -> bool: + return False # pragma: no cover + + @abstractmethod + def get_events(self) -> Dict[socket.socket, int]: + return {} # pragma: no cover + + @abstractmethod + def handle_events(self, + readables: List[Union[int, _HasFileno]], + writables: List[Union[int, _HasFileno]]) -> bool: + """Return True to shutdown work.""" + return False # pragma: no cover + + @abstractmethod + def shutdown(self) -> None: + """Must close any opened resources.""" + pass # pragma: no cover + + +class Threadless(multiprocessing.Process): + """Threadless provides an event loop. Use it by implementing Threadless class. + + When --threadless option is enabled, each Acceptor process also + spawns one Threadless process. And instead of spawning new thread + for each accepted client connection, Acceptor process sends + accepted client connection to Threadless process over a pipe. + + ProtocolHandler implements ThreadlessWork class and hooks into the + event loop provided by Threadless. + """ + + def __init__( + self, + client_queue: connection.Connection, + work_klass: type, + **kwargs: Any) -> None: + super().__init__() + self.client_queue = client_queue + self.work_klass = work_klass + self.kwargs = kwargs + + self.works: Dict[int, ThreadlessWork] = {} + self.selector: Optional[selectors.DefaultSelector] = None + self.loop: Optional[asyncio.AbstractEventLoop] = None + + @contextlib.contextmanager + def selected_events(self) -> Generator[Tuple[List[Union[int, _HasFileno]], + List[Union[int, _HasFileno]]], + None, None]: + events: Dict[socket.socket, int] = {} + for work in self.works.values(): + events.update(work.get_events()) + assert self.selector is not None + for fd in events: + self.selector.register(fd, events[fd]) + ev = self.selector.select(timeout=1) + readables = [] + writables = [] + for key, mask in ev: + if mask & selectors.EVENT_READ: + readables.append(key.fileobj) + if mask & selectors.EVENT_WRITE: + writables.append(key.fileobj) + yield (readables, writables) + for fd in events.keys(): + self.selector.unregister(fd) + + async def handle_events( + self, fileno: int, + readables: List[Union[int, _HasFileno]], + writables: List[Union[int, _HasFileno]]) -> bool: + return self.works[fileno].handle_events(readables, writables) + + # TODO: Use correct future typing annotations + async def wait_for_tasks( + self, tasks: Dict[int, Any]) -> None: + for work_id in tasks: + # TODO: Resolving one handle_events here can block resolution of other tasks + try: + teardown = await asyncio.wait_for(tasks[work_id], DEFAULT_TIMEOUT) + if teardown: + self.cleanup(work_id) + except asyncio.TimeoutError: + self.cleanup(work_id) + + def accept_client(self) -> None: + addr = self.client_queue.recv() + fileno = recv_handle(self.client_queue) + self.works[fileno] = self.work_klass( + fileno=fileno, + addr=addr, + **self.kwargs) + try: + self.works[fileno].initialize() + except ssl.SSLError as e: + logger.exception('ssl.SSLError', exc_info=e) + self.cleanup(fileno) + + def cleanup_inactive(self) -> None: + inactive_works: List[int] = [] + for work_id in self.works: + if self.works[work_id].is_inactive(): + inactive_works.append(work_id) + for work_id in inactive_works: + self.cleanup(work_id) + + def cleanup(self, work_id: int) -> None: + # TODO: ProtocolHandler.shutdown can call flush which may block + self.works[work_id].shutdown() + del self.works[work_id] + + def run_once(self) -> None: + assert self.loop is not None + readables: List[Union[int, _HasFileno]] = [] + writables: List[Union[int, _HasFileno]] = [] + with self.selected_events() as (readables, writables): + if len(readables) == 0 and len(writables) == 0: + # Remove and shutdown inactive connections + self.cleanup_inactive() + return + # Note that selector from now on is idle, + # until all the logic below completes. + # + # Invoke Threadless.handle_events + # TODO: Only send readable / writables that client originally registered. + tasks = {} + for fileno in self.works: + tasks[fileno] = self.loop.create_task( + self.handle_events(fileno, readables, writables)) + # Accepted client connection from Acceptor + if self.client_queue in readables: + self.accept_client() + # Wait for Threadless.handle_events to complete + self.loop.run_until_complete(self.wait_for_tasks(tasks)) + # Remove and shutdown inactive connections + self.cleanup_inactive() + + def run(self) -> None: + try: + self.selector = selectors.DefaultSelector() + self.selector.register(self.client_queue, selectors.EVENT_READ) + self.loop = asyncio.get_event_loop() + while True: + self.run_once() + except KeyboardInterrupt: + pass + finally: + assert self.selector is not None + self.selector.unregister(self.client_queue) + self.client_queue.close() + assert self.loop is not None + self.loop.close() + + +class Acceptor(multiprocessing.Process): """Socket client acceptor. Accepts client connection over received server socket handle and @@ -837,46 +1029,98 @@ class Worker(multiprocessing.Process): def __init__( self, + family: socket.AddressFamily, + threadless: bool, work_queue: connection.Connection, work_klass: type, - **kwargs: Any): + **kwargs: Any) -> None: super().__init__() + self.family: socket.AddressFamily = family + self.threadless: bool = threadless self.work_queue: connection.Connection = work_queue self.work_klass = work_klass self.kwargs = kwargs - self.running = True + + self.running = False + self.selector: Optional[selectors.DefaultSelector] = None + self.sock: Optional[socket.socket] = None + self.threadless_process: Optional[multiprocessing.Process] = None + self.threadless_client_queue: Optional[connection.Connection] = None + + def start_threadless_process(self) -> None: + if not self.threadless: + return + pipe = multiprocessing.Pipe() + self.threadless_client_queue = pipe[0] + self.threadless_process = Threadless( + pipe[1], self.work_klass, **self.kwargs + ) + # self.threadless_process.daemon = True + self.threadless_process.start() + + def shutdown_threadless_process(self) -> None: + if not self.threadless: + return + assert self.threadless_process and self.threadless_client_queue + self.threadless_process.join() + self.threadless_client_queue.close() + + def run_once(self) -> None: + assert self.selector + with self.lock: + events = self.selector.select(timeout=1) + if len(events) == 0: + return + try: + assert self.sock + conn, addr = self.sock.accept() + except BlockingIOError: + return + if self.threadless and \ + self.threadless_client_queue and \ + self.threadless_process: + self.threadless_client_queue.send(addr) + send_handle( + self.threadless_client_queue, + conn.fileno(), + self.threadless_process.pid + ) + conn.close() + else: + # Starting a new thread per client request simply means + # we need 1 million threads to handle a million concurrent + # connections. Since most of the client requests are short + # lived (even with keep-alive), starting threads is excessive. + work = self.work_klass( + fileno=conn.fileno(), + addr=addr, + **self.kwargs) + # work.setDaemon(True) + work.start() def run(self) -> None: - family = self.work_queue.recv() - sock = socket.fromfd( - recv_handle(self.work_queue), - family=family, + self.running = True + self.selector = selectors.DefaultSelector() + fileno = recv_handle(self.work_queue) + self.sock = socket.fromfd( + fileno, + family=self.family, type=socket.SOCK_STREAM ) - selector = selectors.DefaultSelector() + os.close(fileno) try: + self.selector.register(self.sock, selectors.EVENT_READ) + self.start_threadless_process() while self.running: - with self.lock: - selector.register(sock, selectors.EVENT_READ) - events = selector.select(timeout=1) - selector.unregister(sock) - if len(events) == 0: - continue - try: - conn, addr = sock.accept() - except BlockingIOError: # as e: - # logger.exception('BlockingIOError', exc_info=e) - continue - work = self.work_klass( - fileno=conn.fileno(), - addr=addr, - **self.kwargs) - work.setDaemon(True) - work.start() + self.run_once() except KeyboardInterrupt: pass finally: - sock.close() + self.selector.unregister(self.sock) + self.shutdown_threadless_process() + self.sock.close() + self.work_queue.close() + self.running = False class ProtocolException(Exception): @@ -1001,7 +1245,9 @@ def __init__( enable_static_server: bool = DEFAULT_ENABLE_STATIC_SERVER, devtools_event_queue: Optional[DevtoolsEventQueueType] = None, devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH, - timeout: int = DEFAULT_TIMEOUT) -> None: + timeout: int = DEFAULT_TIMEOUT, + threadless: bool = DEFAULT_THREADLESS) -> None: + self.threadless = threadless self.timeout = timeout self.auth_code = auth_code self.server_recvbuf_size = server_recvbuf_size @@ -1205,6 +1451,7 @@ def __init__( self.server: Optional[TcpServerConnection] = None self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) self.pipeline_request: Optional[HttpParser] = None + self.pipeline_response: Optional[HttpParser] = None self.plugins: Dict[str, HttpProxyBasePlugin] = {} if b'HttpProxyBasePlugin' in self.config.plugins: @@ -1234,6 +1481,9 @@ def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: logger.debug('Server is write ready, flushing buffer') try: self.server.flush() + except OSError: + logger.error('OSError when flushing buffer to server') + return True except BrokenPipeError: logger.error( 'BrokenPipeError when flushing buffer for server') @@ -1243,8 +1493,23 @@ def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: if self.request.has_upstream_server( ) and self.server and not self.server.closed and self.server.connection in r: - logger.debug('Server is ready for reads, reading') - raw = self.server.recv(self.config.server_recvbuf_size) + logger.debug('Server is ready for reads, reading...') + raw: Optional[bytes] = None + + try: + raw = self.server.recv(self.config.server_recvbuf_size) + except ssl.SSLWantReadError: # Try again later + # logger.warning('SSLWantReadError encountered while reading from server, will retry ...') + return False + except socket.error as e: + if e.errno == errno.ECONNRESET: + logger.warning('Connection reset by upstream: %r' % e) + else: + logger.exception( + 'Exception while receiving from %s connection %r with reason %r' % + (self.server.tag, self.server.connection, e)) + return True + if not raw: logger.debug('Server closed connection, tearing down...') return True @@ -1255,9 +1520,19 @@ def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: # parse incoming response packet # only for non-https requests and when # tls interception is enabled - if self.request.method != httpMethods.CONNECT or \ - self.config.tls_interception_enabled(): - self.response.parse(raw) + if self.request.method != httpMethods.CONNECT: + # See https://github.com/abhinavsingh/proxy.py/issues/127 for why + # currently response parsing is disabled when TLS interception is enabled. + # + # or self.config.tls_interception_enabled(): + if self.response.state == httpParserStates.COMPLETE: + if self.pipeline_response is None: + self.pipeline_response = HttpParser(httpParserTypes.RESPONSE_PARSER) + self.pipeline_response.parse(raw) + if self.pipeline_response.state == httpParserStates.COMPLETE: + self.pipeline_response = None + else: + self.response.parse(raw) else: self.response.total_size += len(raw) # queue raw data for client @@ -1293,12 +1568,30 @@ def access_log(self) -> None: def on_client_connection_close(self) -> None: if not self.request.has_upstream_server(): return + self.access_log() + + # If server was never initialized, return + if self.server is None: + return + + # Note that, server instance was initialized + # but not necessarily the connection object exists. # Invoke plugin.on_upstream_connection_close - if self.server and not self.server.closed: - for plugin in self.plugins.values(): - plugin.on_upstream_connection_close() - self.server.close() + for plugin in self.plugins.values(): + plugin.on_upstream_connection_close() + + try: + try: + self.server.connection.shutdown(socket.SHUT_WR) + except OSError: + pass + finally: + # TODO: Unwrap if wrapped before close? + self.server.connection.close() + except TcpConnectionUninitializedException: + pass + finally: logger.debug( 'Closed server connection with pending server buffer size %d bytes' % self.server.buffer_size()) @@ -1319,9 +1612,6 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: return raw if self.server and not self.server.closed: - # If 1st request did reach completion stage - # and 1st request was not a CONNECT request - # or if TLS interception was enabled if self.request.state == httpParserStates.COMPLETE and ( self.request.method != httpMethods.CONNECT or self.config.tls_interception_enabled()): @@ -1377,6 +1667,34 @@ def generate_upstream_certificate(self, _certificate: Optional[Dict[str, Any]]) sign_cert.communicate(timeout=10) return cert_file_path + def wrap_server(self) -> None: + assert self.server is not None + assert isinstance(self.server.connection, socket.socket) + ctx = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH) + ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + self.server.connection.setblocking(True) + self.server._conn = ctx.wrap_socket( + self.server.connection, + server_hostname=text_(self.request.host)) + self.server.connection.setblocking(False) + + def wrap_client(self) -> None: + assert self.server is not None + assert isinstance(self.server.connection, ssl.SSLSocket) + generated_cert = self.generate_upstream_certificate( + cast(Dict[str, Any], self.server.connection.getpeercert())) + self.client.connection.setblocking(True) + self.client.flush() + self.client._conn = ssl.wrap_socket( + self.client.connection, + server_side=True, + keyfile=self.config.ca_signing_key_file, + certfile=generated_cert) + self.client.connection.setblocking(False) + logger.debug( + 'TLS interception using %s', generated_cert) + def on_request_complete(self) -> Union[socket.socket, bool]: if not self.request.has_upstream_server(): return False @@ -1409,31 +1727,20 @@ def on_request_complete(self) -> Union[socket.socket, bool]: HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) # If interception is enabled if self.config.tls_interception_enabled(): - assert self.server is not None - assert isinstance(self.server.connection, socket.socket) # Perform SSL/TLS handshake with upstream - ctx = ssl.create_default_context( - ssl.Purpose.SERVER_AUTH) - ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 - self.server.connection.setblocking(True) - self.server._conn = ctx.wrap_socket( - self.server.connection, - server_hostname=text_(self.request.host)) - self.server.connection.setblocking(False) - assert isinstance(self.server.connection, ssl.SSLSocket) + self.wrap_server() # Generate certificate and perform handshake with client - generated_cert = self.generate_upstream_certificate( - cast(Dict[str, Any], self.server.connection.getpeercert())) - self.client.flush() - self.client.connection.setblocking(True) - self.client._conn = ssl.wrap_socket( - self.client.connection, - server_side=True, - keyfile=self.config.ca_signing_key_file, - certfile=generated_cert) - self.client.connection.setblocking(False) - logger.info( - 'TLS interception using %s', generated_cert) + try: + # wrap_client also flushes client data before wrapping + # sending to client can raise, handle expected exceptions + self.wrap_client() + except OSError: + logger.error('OSError when wrapping client') + return True + except BrokenPipeError: + logger.error( + 'BrokenPipeError when wrapping client') + return True # Update all plugin connection reference for plugin in self.plugins.values(): plugin.client._conn = self.client.connection @@ -1688,7 +1995,7 @@ def run(self) -> None: finally: try: self.selector.unregister(self.sock) - self.sock.shutdown(socket.SHUT_RDWR) + self.sock.shutdown(socket.SHUT_WR) except Exception as e: logging.exception('Exception while shutdown of websocket client', exc_info=e) self.sock.close() @@ -1755,7 +2062,7 @@ def start_dispatcher(self) -> None: args=(self.event_dispatcher_shutdown, self.config.devtools_event_queue, self.client)) - self.event_dispatcher_thread.setDaemon(True) + # self.event_dispatcher_thread.setDaemon(True) self.event_dispatcher_thread.start() def stop_dispatcher(self) -> None: @@ -1881,8 +2188,10 @@ def cache_pac_file_response(self) -> None: def routes(self) -> List[Tuple[int, bytes]]: if self.config.pac_file_url_path: - return [(httpProtocolTypes.HTTP, bytes_( - self.config.pac_file_url_path))] + return [ + (httpProtocolTypes.HTTP, bytes_(self.config.pac_file_url_path)), + (httpProtocolTypes.HTTPS, bytes_(self.config.pac_file_url_path)), + ] return [] # pragma: no cover def handle_request(self, request: HttpParser) -> None: @@ -1939,6 +2248,11 @@ def __init__( self.routes[protocol][path] = instance def serve_file_or_404(self, path: str) -> bool: + """Read and serves a file from disk. + + Queues 404 Not Found for IOError. + Shouldn't this be server error? + """ try: with open(path, 'rb') as f: content = f.read() @@ -2030,15 +2344,17 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: frame.reset() return None # If 1st valid request was completed and it's a HTTP/1.1 keep-alive + # And only if we have a route, parse pipeline requests elif self.request.state == httpParserStates.COMPLETE and \ - self.request.is_http_1_1_keep_alive(): + self.request.is_http_1_1_keep_alive() and \ + self.route is not None: if self.pipeline_request is None: self.pipeline_request = HttpParser(httpParserTypes.REQUEST_PARSER) self.pipeline_request.parse(raw) if self.pipeline_request.state == httpParserStates.COMPLETE: - assert self.route is not None self.route.handle_request(self.pipeline_request) if not self.pipeline_request.is_http_1_1_keep_alive(): + logger.error('Pipelined request is not keep-alive, will teardown request...') raise ProtocolException() self.pipeline_request = None return raw @@ -2058,18 +2374,19 @@ def on_client_connection_close(self) -> None: def access_log(self) -> None: logger.info( - '%s:%s - %s %s' % + '%s:%s - %s %s - %.2f ms' % (self.client.addr[0], self.client.addr[1], text_(self.request.method), - text_(self.request.path))) + text_(self.request.path), + (time.time() - self.start_time) * 1000)) def get_descriptors( self) -> Tuple[List[socket.socket], List[socket.socket]]: return [], [] -class ProtocolHandler(threading.Thread): +class ProtocolHandler(threading.Thread, ThreadlessWork): """HTTP, HTTPS, HTTP2, WebSockets protocol handler. Accepts `Client` connection object and manages ProtocolHandlerPlugin invocations. @@ -2081,8 +2398,8 @@ def __init__(self, fileno: int, addr: Tuple[str, int], self.fileno: int = fileno self.addr: Tuple[str, int] = addr - self.start_time: datetime.datetime = self.now() - self.last_activity: datetime.datetime = self.start_time + self.start_time: float = time.time() + self.last_activity: float = self.start_time self.config: ProtocolConfig = config if config else ProtocolConfig() self.request: HttpParser = HttpParser(httpParserTypes.REQUEST_PARSER) @@ -2094,10 +2411,6 @@ def __init__(self, fileno: int, addr: Tuple[str, int], ) self.plugins: Dict[str, ProtocolHandlerPlugin] = {} - @staticmethod - def now() -> datetime.datetime: - return datetime.datetime.utcnow() - def initialize(self) -> None: """Optionally upgrades connection to HTTPS, set conn in non-blocking mode and initializes plugins.""" conn = self.optionally_wrap_socket(self.client.connection) @@ -2108,11 +2421,99 @@ def initialize(self) -> None: for klass in self.config.plugins[b'ProtocolHandlerPlugin']: instance = klass(self.config, self.client, self.request) self.plugins[instance.name()] = instance + logger.debug('Handling connection %r' % self.client.connection) + + def is_inactive(self) -> bool: + if not self.client.has_buffer() and \ + self.connection_inactive_for() > self.config.timeout: + return True + return False + + def get_events(self) -> Dict[socket.socket, int]: + events: Dict[socket.socket, int] = { + self.client.connection: selectors.EVENT_READ + } + if self.client.has_buffer(): + events[self.client.connection] |= selectors.EVENT_WRITE + + # ProtocolHandlerPlugin.get_descriptors + for plugin in self.plugins.values(): + plugin_read_desc, plugin_write_desc = plugin.get_descriptors() + for r in plugin_read_desc: + if r not in events: + events[r] = selectors.EVENT_READ + else: + events[r] |= selectors.EVENT_READ + for w in plugin_write_desc: + if w not in events: + events[w] = selectors.EVENT_WRITE + else: + events[w] |= selectors.EVENT_WRITE + + return events + + def handle_events( + self, + readables: List[Union[int, _HasFileno]], + writables: List[Union[int, _HasFileno]]) -> bool: + """Returns True if proxy must teardown.""" + # Flush buffer for ready to write sockets + teardown = self.handle_writables(writables) + if teardown: + return True + + # Invoke plugin.write_to_descriptors + for plugin in self.plugins.values(): + teardown = plugin.write_to_descriptors(writables) + if teardown: + return True + + # Read from ready to read sockets + teardown = self.handle_readables(readables) + if teardown: + return True + + # Invoke plugin.read_from_descriptors + for plugin in self.plugins.values(): + teardown = plugin.read_from_descriptors(readables) + if teardown: + return True + + return False + + def shutdown(self) -> None: + # Flush pending buffer if any + self.flush() + + # Invoke plugin.on_client_connection_close + for plugin in self.plugins.values(): + plugin.on_client_connection_close() + + logger.debug( + 'Closing client connection %r ' + 'at address %r with pending client buffer size %d bytes' % + (self.client.connection, self.client.addr, self.client.buffer_size())) + + conn = self.client.connection + try: + # Unwrap if wrapped before shutdown. + if self.config.encryption_enabled() and \ + isinstance(self.client.connection, ssl.SSLSocket): + conn = self.client.connection.unwrap() + conn.shutdown(socket.SHUT_WR) + logger.debug('Client connection shutdown successful') + except OSError: + pass + finally: + conn.close() + logger.debug('Client connection closed') def fromfd(self, fileno: int) -> socket.socket: - return socket.fromfd( + conn = socket.fromfd( fileno, family=socket.AF_INET if self.config.hostname.version == 4 else socket.AF_INET6, type=socket.SOCK_STREAM) + os.close(fileno) + return conn def optionally_wrap_socket( self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]: @@ -2132,16 +2533,28 @@ def optionally_wrap_socket( conn = ctx.wrap_socket(conn, server_side=True) return conn - def connection_inactive_for(self) -> int: - return (self.now() - self.last_activity).seconds + def connection_inactive_for(self) -> float: + return time.time() - self.last_activity - def is_connection_inactive(self) -> bool: - return self.connection_inactive_for() > self.config.timeout + def flush(self) -> None: + if not self.client.has_buffer(): + return + try: + self.selector.register(self.client.connection, selectors.EVENT_WRITE) + while self.client.has_buffer(): + ev: List[Tuple[selectors.SelectorKey, int]] = self.selector.select(timeout=1) + if len(ev) == 0: + continue + self.client.flush() + except BrokenPipeError: + pass + finally: + self.selector.unregister(self.client.connection) def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool: if self.client.buffer_size() > 0 and self.client.connection in writables: logger.debug('Client is ready for writes, flushing buffer') - self.last_activity = self.now() + self.last_activity = time.time() # Invoke plugin.on_response_chunk chunk = self.client.buffer @@ -2152,6 +2565,9 @@ def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool: try: self.client.flush() + except OSError: + logger.error('OSError when flushing buffer to client') + return True except BrokenPipeError: logger.error( 'BrokenPipeError when flushing buffer for client') @@ -2161,9 +2577,23 @@ def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool: def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: if self.client.connection in readables: logger.debug('Client is ready for reads, reading') - self.last_activity = self.now() + self.last_activity = time.time() + client_data: Optional[bytes] = None + + try: + client_data = self.client.recv(self.config.client_recvbuf_size) + except ssl.SSLWantReadError: # Try again later + logger.warning('SSLWantReadError encountered while reading from client, will retry ...') + return False + except socket.error as e: + if e.errno == errno.ECONNRESET: + logger.warning('%r' % e) + else: + logger.exception( + 'Exception while receiving from %s connection %r with reason %r' % + (self.client.tag, self.client.connection, e)) + return True - client_data = self.client.recv(self.config.client_recvbuf_size) if not client_data: logger.debug('Client closed connection, tearing down...') self.client.closed = True @@ -2198,8 +2628,6 @@ def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: for plugin_ in self.plugins.values(): if plugin_ != plugin: plugin_.client._conn = upgraded_sock - logger.debug( - 'Upgraded client conn for plugin %s', str(plugin_)) elif isinstance(upgraded_sock, bool) and upgraded_sock is True: return True except ProtocolException as e: @@ -2211,116 +2639,43 @@ def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: return True return False - def get_events(self) -> Dict[socket.socket, int]: - events: Dict[socket.socket, int] = { - self.client.connection: selectors.EVENT_READ - } - if self.client.has_buffer(): - events[self.client.connection] |= selectors.EVENT_WRITE - - # ProtocolHandlerPlugin.get_descriptors - for plugin in self.plugins.values(): - plugin_read_desc, plugin_write_desc = plugin.get_descriptors() - for r in plugin_read_desc: - if r not in events: - events[r] = selectors.EVENT_READ - else: - events[r] |= selectors.EVENT_READ - for w in plugin_write_desc: - if w not in events: - events[w] = selectors.EVENT_WRITE - else: - events[w] |= selectors.EVENT_WRITE - - return events - - def handle_events(self, readables: List[Union[int, _HasFileno]], writables: List[Union[int, _HasFileno]]) -> bool: - """Returns True if proxy must teardown.""" - # Flush buffer for ready to write sockets - teardown = self.handle_writables(writables) - if teardown: - return True - - # Invoke plugin.write_to_descriptors - for plugin in self.plugins.values(): - teardown = plugin.write_to_descriptors(writables) - if teardown: - return True - - # Read from ready to read sockets - teardown = self.handle_readables(readables) - if teardown: - return True - - # Invoke plugin.read_from_descriptors - for plugin in self.plugins.values(): - teardown = plugin.read_from_descriptors(readables) - if teardown: - return True - - # Teardown if client buffer is empty and connection is inactive - if not self.client.has_buffer() and \ - self.is_connection_inactive(): - self.client.queue(build_http_response( - httpStatusCodes.REQUEST_TIMEOUT, reason=b'Request Timeout', - headers={ - b'Server': PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close', - } - )) - logger.debug( - 'Client buffer is empty and maximum inactivity has reached ' - 'between client and server connection, tearing down...') - return True - - return False - - def run_once(self) -> bool: + @contextlib.contextmanager + def selected_events(self) -> \ + Generator[Tuple[List[Union[int, _HasFileno]], + List[Union[int, _HasFileno]]], + None, None]: events = self.get_events() for fd in events: self.selector.register(fd, events[fd]) - - # Select - e: List[Tuple[selectors.SelectorKey, int]] = self.selector.select(timeout=1) + ev = self.selector.select(timeout=1) readables = [] writables = [] - for key, mask in e: + for key, mask in ev: if mask & selectors.EVENT_READ: readables.append(key.fileobj) if mask & selectors.EVENT_WRITE: writables.append(key.fileobj) - - teardown = self.handle_events(readables, writables) - - # Unregister + yield (readables, writables) for fd in events.keys(): self.selector.unregister(fd) - if teardown: - return True - return False - - def flush(self) -> None: - if not self.client.has_buffer(): - return - try: - self.selector.register(self.client.connection, selectors.EVENT_WRITE) - while self.client.has_buffer(): - ev: List[Tuple[selectors.SelectorKey, int]] = self.selector.select(timeout=1) - if len(ev) == 0: - continue - self.client.flush() - except BrokenPipeError: - pass - finally: - self.selector.unregister(self.client.connection) + def run_once(self) -> bool: + with self.selected_events() as (readables, writables): + teardown = self.handle_events(readables, writables) + if teardown: + return True + return False def run(self) -> None: try: self.initialize() - logger.debug('Handling connection %r' % self.client.connection) - while True: + # Teardown if client buffer is empty and connection is inactive + if self.is_inactive(): + logger.debug( + 'Client buffer is empty and maximum inactivity has reached ' + 'between client and server connection, tearing down...') + break teardown = self.run_once() if teardown: break @@ -2333,27 +2688,7 @@ def run(self) -> None: 'Exception while handling connection %r' % self.client.connection, exc_info=e) finally: - # Flush pending buffer if any - self.flush() - - # Invoke plugin.on_client_connection_close - for plugin in self.plugins.values(): - plugin.on_client_connection_close() - - logger.debug( - 'Closing proxy for connection %r ' - 'at address %r with pending client buffer size %d bytes' % - (self.client.connection, self.client.addr, self.client.buffer_size())) - - if not self.client.closed: - try: - self.client.connection.shutdown(socket.SHUT_WR) - logger.debug('Client connection shutdown successful') - except OSError: - pass - finally: - self.client.connection.close() - logger.debug('Client connection closed') + self.shutdown() class DevtoolsProtocolPlugin(ProtocolHandlerPlugin): @@ -2755,10 +3090,17 @@ def init_parser() -> argparse.ArgumentParser: '--static-server-dir', type=str, default=DEFAULT_STATIC_SERVER_DIR, - help='Default: ' + DEFAULT_STATIC_SERVER_DIR + '. Static server root directory. ' + help='Default: "public" folder in directory where proxy.py is placed. ' 'This option is only applicable when static server is also enabled. ' 'See --enable-static-server.' ) + parser.add_argument( + '--threadless', + action='store_true', + default=DEFAULT_THREADLESS, + help='Default: False. When disabled a new thread is spawned ' + 'to handle each client connection.' + ) parser.add_argument( '--timeout', type=int, @@ -2796,7 +3138,8 @@ def main(input_args: List[str]) -> None: if (args.cert_file and args.key_file) and \ (args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file): - print('HTTPS interception not supported when proxy.py is serving over HTTPS') + print('You can either enable end-to-end encryption OR TLS interception,' + 'not both together.') sys.exit(0) try: @@ -2848,7 +3191,8 @@ def main(input_args: List[str]) -> None: enable_static_server=args.enable_static_server, devtools_event_queue=devtools_event_queue, devtools_ws_path=args.devtools_ws_path, - timeout=args.timeout) + timeout=args.timeout, + threadless=args.threadless) config.plugins = load_plugins( bytes_( @@ -2860,6 +3204,7 @@ def main(input_args: List[str]) -> None: port=config.port, backlog=config.backlog, num_workers=config.num_workers, + threadless=config.threadless, work_klass=ProtocolHandler, config=config) if args.pid_file: diff --git a/tests.py b/tests.py index 8111f0f718..dddcd2a2f9 100644 --- a/tests.py +++ b/tests.py @@ -8,7 +8,6 @@ :license: BSD, see LICENSE for more details. """ import base64 -import errno import ipaddress import json import logging @@ -99,13 +98,6 @@ def connection(self) -> Union[ssl.SSLSocket, socket.socket]: raise proxy.TcpConnectionUninitializedException() return self._conn - def testFlushThrowsBrokenPipeIfClosed(self) -> None: - self.conn = TestTcpConnection.TcpConnectionToTest() - self.conn.queue(b'some data') - self.conn.closed = True - with self.assertRaises(BrokenPipeError): - self.conn.flush() - def testThrowsKeyErrorIfNoConn(self) -> None: self.conn = TestTcpConnection.TcpConnectionToTest() with self.assertRaises(proxy.TcpConnectionUninitializedException): @@ -115,28 +107,6 @@ def testThrowsKeyErrorIfNoConn(self) -> None: with self.assertRaises(proxy.TcpConnectionUninitializedException): self.conn.close() - def testHandlesIOError(self) -> None: - _conn = mock.MagicMock() - _conn.recv.side_effect = IOError() - self.conn = TestTcpConnection.TcpConnectionToTest(_conn) - with mock.patch('proxy.logger') as mock_logger: - self.conn.recv() - mock_logger.exception.assert_called() - logging.info(mock_logger.exception.call_args[0][0].startswith( - 'Exception while receiving from connection')) - - def testHandlesConnReset(self) -> None: - _conn = mock.MagicMock() - e = IOError() - e.errno = errno.ECONNRESET - _conn.recv.side_effect = e - self.conn = TestTcpConnection.TcpConnectionToTest(_conn) - with mock.patch('proxy.logger') as mock_logger: - self.conn.recv() - mock_logger.exception.assert_not_called() - mock_logger.debug.assert_called() - self.assertEqual(mock_logger.debug.call_args[0][0], '%r' % e) - def testClosesIfNotClosed(self) -> None: _conn = mock.MagicMock() self.conn = TestTcpConnection.TcpConnectionToTest(_conn) @@ -258,7 +228,7 @@ class TestAcceptorPool(unittest.TestCase): @mock.patch('proxy.send_handle') @mock.patch('multiprocessing.Pipe') @mock.patch('socket.socket') - @mock.patch('proxy.Worker') + @mock.patch('proxy.Acceptor') def test_setup_and_shutdown( self, mock_worker: mock.Mock, @@ -278,6 +248,7 @@ def test_setup_and_shutdown( proxy.DEFAULT_PORT, proxy.DEFAULT_BACKLOG, num_workers, + threadless=proxy.DEFAULT_THREADLESS, work_klass=work_klass, **kwargs ) @@ -292,7 +263,6 @@ def test_setup_and_shutdown( sock.bind.assert_called_with((str(acceptor.hostname), acceptor.port)) sock.listen.assert_called_with(acceptor.backlog) sock.setblocking.assert_called_with(False) - sock.settimeout.assert_called_with(0) self.assertTrue(mock_pipe.call_count, num_workers) self.assertTrue(mock_worker.call_count, num_workers) @@ -311,15 +281,20 @@ def test_setup_and_shutdown( class TestWorker(unittest.TestCase): @mock.patch('proxy.ProtocolHandler') - def setUp(self, mock_protocol_handler: mock.Mock) -> None: + def setUp( + self, + mock_protocol_handler: mock.Mock) -> None: + self.mock_protocol_handler = mock_protocol_handler self.pipe = multiprocessing.Pipe() self.protocol_config = proxy.ProtocolConfig() - self.worker = proxy.Worker( + self.worker = proxy.Acceptor( + socket.AF_INET6, + proxy.DEFAULT_THREADLESS, self.pipe[1], mock_protocol_handler, config=self.protocol_config) - self.mock_protocol_handler = mock_protocol_handler + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.recv_handle') @@ -327,7 +302,8 @@ def test_continues_when_no_events( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -338,12 +314,12 @@ def test_continues_when_no_events( selector = mock_selector.return_value selector.select.side_effect = [[], KeyboardInterrupt()] - self.pipe[0].send(socket.AF_INET6) self.worker.run() sock.accept.assert_not_called() self.mock_protocol_handler.assert_not_called() + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.recv_handle') @@ -351,7 +327,8 @@ def test_worker_doesnt_teardown_on_blocking_io_error( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -363,11 +340,11 @@ def test_worker_doesnt_teardown_on_blocking_io_error( selector.select.side_effect = [(None, None), KeyboardInterrupt()] sock.accept.side_effect = BlockingIOError() - self.pipe[0].send(socket.AF_INET6) self.worker.run() self.mock_protocol_handler.assert_not_called() + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.recv_handle') @@ -375,7 +352,8 @@ def test_accepts_client_from_server_socket( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -388,7 +366,6 @@ def test_accepts_client_from_server_socket( selector = mock_selector.return_value selector.select.return_value = [(None, None)] - self.pipe[0].send(socket.AF_INET6) self.worker.run() selector.register.assert_called_with(sock, selectors.EVENT_READ) @@ -404,7 +381,7 @@ def test_accepts_client_from_server_socket( addr=addr, **{'config': self.protocol_config} ) - self.mock_protocol_handler.return_value.setDaemon.assert_called() + # self.mock_protocol_handler.return_value.setDaemon.assert_called() self.mock_protocol_handler.return_value.start.assert_called() sock.close.assert_called() @@ -996,9 +973,13 @@ def test_handshake(self, mock_connect: mock.Mock, mock_b64encode: mock.Mock) -> class TestHttpProtocolHandler(unittest.TestCase): + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self._conn = mock_fromfd.return_value @@ -1011,6 +992,7 @@ def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self.mock_selector = mock_selector self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() @mock.patch('proxy.TcpServerConnection') @@ -1124,10 +1106,14 @@ def test_proxy_connection_failed(self) -> None: self.proxy.run_once() self.assertEqual(self.proxy.client.buffer, proxy.ProxyConnectionFailed.RESPONSE_PKT) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_proxy_authentication_failed( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) config = proxy.ProtocolConfig( @@ -1137,6 +1123,7 @@ def test_proxy_authentication_failed( b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self._conn.recv.return_value = proxy.CRLF.join([ b'GET http://abhinavsingh.com HTTP/1.1', @@ -1148,13 +1135,15 @@ def test_proxy_authentication_failed( self.proxy.client.buffer, proxy.ProxyAuthenticationFailed.RESPONSE_PKT) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.TcpServerConnection') def test_authenticated_proxy_http_get( self, mock_server_connection: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) @@ -1170,6 +1159,7 @@ def test_authenticated_proxy_http_get( self.proxy = proxy.ProtocolHandler( self.fileno, addr=self._addr, config=config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() assert self.http_server_port is not None @@ -1196,13 +1186,15 @@ def test_authenticated_proxy_http_get( ]) self.assert_data_queued(mock_server_connection, server) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.TcpServerConnection') def test_authenticated_proxy_http_tunnel( self, mock_server_connection: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: server = mock_server_connection.return_value server.connect.return_value = True server.buffer_size.return_value = 0 @@ -1217,6 +1209,7 @@ def test_authenticated_proxy_http_tunnel( self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() assert self.http_server_port is not None @@ -1310,9 +1303,10 @@ def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: class TestWebServerPlugin(unittest.TestCase): + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, mock_os_close: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self._conn = mock_fromfd.return_value @@ -1322,16 +1316,20 @@ def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_pac_file_served_from_disk( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: pac_file = 'proxy.pac' self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) self.init_and_make_pac_file_request(pac_file) + mock_os_close.assert_called_with(self.fileno) self.proxy.run_once() self.assertEqual( self.proxy.request.state, @@ -1344,14 +1342,17 @@ def test_pac_file_served_from_disk( }, body=f.read() )) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_pac_file_served_from_buffer( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) pac_file_content = b'function FindProxyForURL(url, host) { return "PROXY localhost:8899; DIRECT"; }' self.init_and_make_pac_file_request(proxy.text_(pac_file_content)) + mock_os_close.assert_called_with(self.fileno) self.proxy.run_once() self.assertEqual( self.proxy.request.state, @@ -1363,10 +1364,12 @@ def test_pac_file_served_from_buffer( }, body=pac_file_content )) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_default_web_server_returns_404( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self._conn = mock_fromfd.return_value mock_selector.return_value.select.return_value = [( selectors.SelectorKey( @@ -1379,6 +1382,7 @@ def test_default_web_server_returns_404( b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self._conn.recv.return_value = proxy.CRLF.join([ b'GET /hello HTTP/1.1', @@ -1392,10 +1396,12 @@ def test_default_web_server_returns_404( self.proxy.client.buffer, proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_static_web_server_serves( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: # Setup a static directory static_server_dir = os.path.join(tempfile.gettempdir(), 'static') index_file_path = os.path.join(static_server_dir, 'index.html') @@ -1447,10 +1453,14 @@ def test_static_web_server_serves( body=html_file_content )) + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_static_web_server_serves_404( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self._conn = mock_fromfd.return_value self._conn.recv.return_value = proxy.build_http_request(b'GET', b'/not-found.html') @@ -1472,6 +1482,7 @@ def test_static_web_server_serves_404( self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self.proxy.run_once() @@ -1482,15 +1493,17 @@ def test_static_web_server_serves_404( self.assertEqual(self._conn.send.call_args[0][0], proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE) + @mock.patch('os.close') @mock.patch('socket.fromfd') def test_on_client_connection_called_on_teardown( - self, mock_fromfd: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_os_close: mock.Mock) -> None: config = proxy.ProtocolConfig() plugin = mock.MagicMock() config.plugins = {b'ProtocolHandlerPlugin': [plugin]} self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() plugin.assert_called() with mock.patch.object(self.proxy, 'run_once') as mock_run_once: @@ -1522,11 +1535,13 @@ def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: class TestHttpProxyPlugin(unittest.TestCase): + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def setUp(self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self.mock_fromfd = mock_fromfd self.mock_selector = mock_selector @@ -1541,6 +1556,7 @@ def setUp(self, self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() def test_proxy_plugin_initialized(self) -> None: @@ -1595,11 +1611,13 @@ def test_proxy_plugin_before_upstream_connection_can_teardown( class TestHttpProxyPluginExamples(unittest.TestCase): + @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def setUp(self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: + mock_selector: mock.Mock, + mock_os_close: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self.config = proxy.ProtocolConfig() @@ -1617,6 +1635,7 @@ def setUp(self, self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() @mock.patch('proxy.TcpServerConnection') @@ -1743,11 +1762,6 @@ def test_filter_by_upstream_host_plugin( ) ) - @mock.patch('proxy.TcpServerConnection') - def test_cache_responses_plugin( - self, mock_server_conn: mock.Mock) -> None: - pass - @mock.patch('proxy.TcpServerConnection') def test_man_in_the_middle_plugin( self, mock_server_conn: mock.Mock) -> None: @@ -1822,6 +1836,7 @@ def closed() -> bool: class TestHttpProxyTlsInterception(unittest.TestCase): + @mock.patch('os.close') @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') @mock.patch('proxy.TcpServerConnection') @@ -1835,7 +1850,8 @@ def test_e2e( mock_popen: mock.Mock, mock_server_conn: mock.Mock, mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock) -> None: + mock_ssl_wrap: mock.Mock, + mock_os_close: mock.Mock) -> None: host, port = uuid.uuid4().hex, 443 netloc = '{0}:{1}'.format(host, port) @@ -1875,6 +1891,7 @@ def mock_connection() -> Any: self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self.plugin.assert_called() @@ -1953,6 +1970,7 @@ def mock_connection() -> Any: class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): + @mock.patch('os.close') @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') @mock.patch('proxy.TcpServerConnection') @@ -1965,7 +1983,8 @@ def setUp(self, mock_popen: mock.Mock, mock_server_conn: mock.Mock, mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock) -> None: + mock_ssl_wrap: mock.Mock, + mock_os_close: mock.Mock) -> None: self.mock_fromfd = mock_fromfd self.mock_selector = mock_selector self.mock_popen = mock_popen @@ -1991,6 +2010,7 @@ def setUp(self, mock_fromfd.return_value = self._conn self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) + mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self.server = self.mock_server_conn.return_value @@ -2082,11 +2102,6 @@ def test_modify_post_data_plugin(self) -> None: ) ) - @mock.patch('proxy.TcpServerConnection') - def test_cache_responses_plugin( - self, mock_server_conn: mock.Mock) -> None: - pass - @mock.patch('proxy.TcpServerConnection') def test_man_in_the_middle_plugin( self, mock_server_conn: mock.Mock) -> None: @@ -2180,6 +2195,7 @@ def mock_default_args(mock_args: mock.Mock) -> None: mock_args.devtools_event_queue = None mock_args.devtools_ws_path = proxy.DEFAULT_DEVTOOLS_WS_PATH mock_args.timeout = proxy.DEFAULT_TIMEOUT + mock_args.threadless = proxy.DEFAULT_THREADLESS @mock.patch('time.sleep') @mock.patch('proxy.load_plugins') @@ -2236,7 +2252,8 @@ def test_init_with_no_arguments( enable_static_server=mock_args.enable_static_server, devtools_event_queue=None, devtools_ws_path=proxy.DEFAULT_DEVTOOLS_WS_PATH, - timeout=proxy.DEFAULT_TIMEOUT + timeout=proxy.DEFAULT_TIMEOUT, + threadless=proxy.DEFAULT_THREADLESS, ) mock_acceptor_pool.assert_called_with( @@ -2245,6 +2262,7 @@ def test_init_with_no_arguments( backlog=mock_protocol_config.return_value.backlog, num_workers=mock_protocol_config.return_value.num_workers, work_klass=proxy.ProtocolHandler, + threadless=mock_protocol_config.return_value.threadless, config=mock_protocol_config.return_value, ) mock_acceptor_pool.return_value.setup.assert_called() @@ -2297,6 +2315,7 @@ def test_basic_auth( backlog=config.backlog, num_workers=config.num_workers, work_klass=proxy.ProtocolHandler, + threadless=config.threadless, config=config) self.assertEqual(mock_protocol_config.call_args[1]['auth_code'], b'Basic dXNlcjpwYXNz') From 69445a8921ba82738bde1f64e0939a34d08aaae2 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 16 Oct 2019 01:13:27 -0700 Subject: [PATCH 010/107] Remove pip upgrade for windows which seems to be failing on travis (#136) * Remove pip upgrade for windows which seems to be failing on travis * Remove windows testing on Travis, pip install is failing --- .travis.yml | 9 ------- benchmark.py | 69 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/.travis.yml b/.travis.yml index ed96ea43de..809068010e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,15 +10,6 @@ matrix: osx_image: xcode11 language: shell python: 3.7 - - name: "Python 3.7 on Windows" - os: windows - language: shell - before_install: - - choco install python - - python -m pip install --upgrade pip - env: - - PATH=/c/Python37:/c/Python37/Scripts:$PATH - - TESTING_ON_TRAVIS=1 install: - pip3 install -r requirements-testing.txt script: python3 -m coverage run --source=proxy tests.py || python -m coverage run --source=proxy tests.py diff --git a/benchmark.py b/benchmark.py index 3f42825015..9c69ff054c 100755 --- a/benchmark.py +++ b/benchmark.py @@ -11,6 +11,7 @@ import argparse import asyncio import sys +import time from typing import List, Tuple import proxy @@ -48,17 +49,36 @@ async def open_connections(self) -> None: self.clients.append(await asyncio.open_connection('::', 8899)) print('Opened ' + str(self.n) + ' connections') - def send_requests(self) -> None: - for _, writer in self.clients: - writer.write(proxy.build_http_request( - proxy.httpMethods.GET, b'/' - )) + @staticmethod + async def send(writer: asyncio.StreamWriter) -> None: + try: + while True: + writer.write(proxy.build_http_request( + proxy.httpMethods.GET, b'/' + )) + # await asyncio.sleep(0.1) + except KeyboardInterrupt: + pass + + @staticmethod + async def recv(idd: int, reader: asyncio.StreamReader) -> None: + last_status_time = time.time() + num_completed_requests_per_connection: int = 0 + try: + while True: + response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + while response.state != proxy.httpParserStates.COMPLETE: + raw = await reader.read(proxy.DEFAULT_BUFFER_SIZE) + print(raw) + response.parse(raw) - async def recv_responses(self) -> None: - for reader, _ in self.clients: - response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) - while response.state != proxy.httpParserStates.COMPLETE: - response.parse(await reader.read(proxy.DEFAULT_BUFFER_SIZE)) + num_completed_requests_per_connection += 1 + if num_completed_requests_per_connection % 50 == 0: + now = time.time() + print('[%d] Made 50 requests in last %.2f seconds' % (idd, now - last_status_time)) + last_status_time = now + except KeyboardInterrupt: + pass async def close_connections(self) -> None: for reader, writer in self.clients: @@ -67,19 +87,30 @@ async def close_connections(self) -> None: print('Closed ' + str(self.n) + ' connections') async def run(self) -> None: - num_completed_requests_per_connection: int = 0 try: await self.open_connections() print('Exchanging request / response packets') - while True: - self.send_requests() - await self.recv_responses() - num_completed_requests_per_connection += 1 - await asyncio.sleep(0.1) + readers = [] + writers = [] + idd = 0 + for reader, writer in self.clients: + readers.append( + asyncio.create_task( + self.recv(idd, reader) + ) + ) + writers.append( + asyncio.create_task( + self.send(writer) + ) + ) + idd += 1 + await asyncio.gather(*(readers + writers)) finally: - await self.close_connections() - print('Exchanged ' + str(num_completed_requests_per_connection) + - ' request / response per connection') + try: + await self.close_connections() + except RuntimeError: + pass def main(input_args: List[str]) -> None: From c77f8b57894840f902fbdfaa18583c126e4fb9d2 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 16 Oct 2019 03:22:08 -0700 Subject: [PATCH 011/107] Add pipeline response parsing tests (#137) * Add pipeline response parsing tests * build_http_response now only adds content-length if transfer-encoding is not provided. Also return pending raw chunks from ChunkParser so that we can parse pipelined chunk responses. --- benchmark.py | 47 +++++++++++++++++++++++++++++++++-------------- proxy.py | 34 +++++++++++++++++++++++----------- tests.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 25 deletions(-) diff --git a/benchmark.py b/benchmark.py index 9c69ff054c..5ce54142e4 100755 --- a/benchmark.py +++ b/benchmark.py @@ -56,27 +56,46 @@ async def send(writer: asyncio.StreamWriter) -> None: writer.write(proxy.build_http_request( proxy.httpMethods.GET, b'/' )) - # await asyncio.sleep(0.1) + await asyncio.sleep(0.01) except KeyboardInterrupt: pass + @staticmethod + def parse_pipeline_response(response: proxy.HttpParser, raw: bytes, counter: int = 0) -> \ + Tuple[proxy.HttpParser, int]: + response.parse(raw) + if response.state != proxy.httpParserStates.COMPLETE: + # Need more data + return response, counter + + if response.buffer == b'': + # No more buffer left to parse + return response, counter + 1 + + # For pipelined requests we may have pending buffer, try parse them as responses + pipelined_response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + return Benchmark.parse_pipeline_response(pipelined_response, response.buffer, counter + 1) + @staticmethod async def recv(idd: int, reader: asyncio.StreamReader) -> None: - last_status_time = time.time() - num_completed_requests_per_connection: int = 0 + print_every = 1000 + last_print = time.time() + num_completed_requests: int = 0 + response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) try: while True: - response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) - while response.state != proxy.httpParserStates.COMPLETE: - raw = await reader.read(proxy.DEFAULT_BUFFER_SIZE) - print(raw) - response.parse(raw) - - num_completed_requests_per_connection += 1 - if num_completed_requests_per_connection % 50 == 0: - now = time.time() - print('[%d] Made 50 requests in last %.2f seconds' % (idd, now - last_status_time)) - last_status_time = now + raw = await reader.read(proxy.DEFAULT_BUFFER_SIZE) + response, total_parsed = Benchmark.parse_pipeline_response(response, raw) + if response.state == proxy.httpParserStates.COMPLETE: + response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + if total_parsed > 0: + num_completed_requests += total_parsed + # print('total parsed %d' % total_parsed) + if num_completed_requests % print_every == 0: + now = time.time() + print('[%d] Completed last %d requests in %.2f secs' % + (idd, print_every, now - last_print)) + last_print = now except KeyboardInterrupt: pass diff --git a/proxy.py b/proxy.py index 4324505605..6631abcc56 100755 --- a/proxy.py +++ b/proxy.py @@ -257,8 +257,16 @@ def build_http_response(status_code: int, line.append(reason) if headers is None: headers = {} - if body is not None and not any( - k.lower() == b'content-length' for k in headers): + has_content_length = False + has_transfer_encoding = False + for k in headers: + if k.lower() == b'content-length': + has_content_length = True + if k.lower() == b'transfer-encoding': + has_transfer_encoding = True + if body is not None and \ + not has_transfer_encoding and \ + not has_content_length: headers[b'Content-Length'] = bytes_(len(body)) return build_http_pkt(line, headers, body) @@ -501,10 +509,11 @@ def __init__(self) -> None: # Expected size of next following chunk self.size: Optional[int] = None - def parse(self, raw: bytes) -> None: + def parse(self, raw: bytes) -> bytes: more = True if len(raw) > 0 else False - while more: + while more and self.state != chunkParserStates.COMPLETE: more, raw = self.process(raw) + return raw def process(self, raw: bytes) -> Tuple[bool, bytes]: if self.state == chunkParserStates.WAITING_FOR_SIZE: @@ -651,27 +660,29 @@ def parse(self, raw: bytes) -> None: self.buffer = b'' more = True if len(raw) > 0 else False - while more: + while more and self.state != httpParserStates.COMPLETE: if self.state in ( httpParserStates.HEADERS_COMPLETE, - httpParserStates.RCVING_BODY, - httpParserStates.COMPLETE): + httpParserStates.RCVING_BODY): if b'content-length' in self.headers: self.state = httpParserStates.RCVING_BODY if self.body is None: self.body = b'' - self.body += raw + total_size = int(self.header(b'content-length')) + received_size = len(self.body) + self.body += raw[:total_size - received_size] if self.body and \ - len(self.body) >= int(self.header(b'content-length')): + len(self.body) == int(self.header(b'content-length')): self.state = httpParserStates.COMPLETE + more, raw = len(raw) > 0, raw[total_size - received_size:] elif self.is_chunked_encoded(): if not self.chunk_parser: self.chunk_parser = ChunkParser() - self.chunk_parser.parse(raw) + raw = self.chunk_parser.parse(raw) if self.chunk_parser.state == chunkParserStates.COMPLETE: self.body = self.chunk_parser.body self.state = httpParserStates.COMPLETE - more, raw = False, b'' + more = False else: more, raw = self.process(raw) self.buffer = raw @@ -713,6 +724,7 @@ def process(self, raw: bytes) -> Tuple[bool, bytes]: elif self.state == httpParserStates.HEADERS_COMPLETE and \ self.type == httpParserTypes.REQUEST_PARSER and \ self.method == httpMethods.POST and \ + not self.is_chunked_encoded() and \ (b'content-length' not in self.headers or (b'content-length' in self.headers and int(self.headers[b'content-length'][1]) == 0)) and \ diff --git a/tests.py b/tests.py index dddcd2a2f9..b4d0e864f2 100644 --- a/tests.py +++ b/tests.py @@ -874,6 +874,41 @@ def test_chunked_response_parse(self) -> None: self.assertEqual(self.parser.body, b'Wikipedia in\r\n\r\nchunks.') self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) + def test_pipelined_response_parse(self) -> None: + response = proxy.build_http_response( + proxy.httpStatusCodes.OK, reason=b'OK', + headers={ + b'Content-Length': b'15' + }, + body=b'{"key":"value"}', + ) + self.assert_pipeline_response(response) + + def test_pipelined_chunked_response_parse(self) -> None: + response = proxy.build_http_response( + proxy.httpStatusCodes.OK, reason=b'OK', + headers={ + b'Transfer-Encoding': b'chunked', + b'Content-Type': b'application/json', + }, + body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n' + ) + self.assert_pipeline_response(response) + + def assert_pipeline_response(self, response: bytes) -> None: + self.parser = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + self.parser.parse(response + response) + self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) + self.assertEqual(self.parser.body, b'{"key":"value"}') + self.assertEqual(self.parser.buffer, response) + + # parse buffer + parser = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + parser.parse(self.parser.buffer) + self.assertEqual(parser.state, proxy.httpParserStates.COMPLETE) + self.assertEqual(parser.body, b'{"key":"value"}') + self.assertEqual(parser.buffer, b'') + def test_chunked_request_parse(self) -> None: self.parser.parse(proxy.build_http_request( proxy.httpMethods.POST, b'http://example.org/', From ca1d1e713963c9e8266d0ffa6986df1064b46503 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 16 Oct 2019 13:09:38 -0700 Subject: [PATCH 012/107] os.close only for threadless (#138) * os.close only for Threadless to avoid fd leaks * Remove os.close mock which is only called for threadless --- proxy.py | 3 +- tests.py | 84 ++++++++++++-------------------------------------------- 2 files changed, 19 insertions(+), 68 deletions(-) diff --git a/proxy.py b/proxy.py index 6631abcc56..aaad662b2b 100755 --- a/proxy.py +++ b/proxy.py @@ -970,6 +970,7 @@ def accept_client(self) -> None: **self.kwargs) try: self.works[fileno].initialize() + os.close(fileno) except ssl.SSLError as e: logger.exception('ssl.SSLError', exc_info=e) self.cleanup(fileno) @@ -1119,7 +1120,6 @@ def run(self) -> None: family=self.family, type=socket.SOCK_STREAM ) - os.close(fileno) try: self.selector.register(self.sock, selectors.EVENT_READ) self.start_threadless_process() @@ -2524,7 +2524,6 @@ def fromfd(self, fileno: int) -> socket.socket: conn = socket.fromfd( fileno, family=socket.AF_INET if self.config.hostname.version == 4 else socket.AF_INET6, type=socket.SOCK_STREAM) - os.close(fileno) return conn def optionally_wrap_socket( diff --git a/tests.py b/tests.py index b4d0e864f2..1445cb904a 100644 --- a/tests.py +++ b/tests.py @@ -294,7 +294,6 @@ def setUp( mock_protocol_handler, config=self.protocol_config) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.recv_handle') @@ -302,8 +301,7 @@ def test_continues_when_no_events( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -319,7 +317,6 @@ def test_continues_when_no_events( sock.accept.assert_not_called() self.mock_protocol_handler.assert_not_called() - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.recv_handle') @@ -327,8 +324,7 @@ def test_worker_doesnt_teardown_on_blocking_io_error( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -344,7 +340,6 @@ def test_worker_doesnt_teardown_on_blocking_io_error( self.mock_protocol_handler.assert_not_called() - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.recv_handle') @@ -352,8 +347,7 @@ def test_accepts_client_from_server_socket( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -1008,13 +1002,11 @@ def test_handshake(self, mock_connect: mock.Mock, mock_b64encode: mock.Mock) -> class TestHttpProtocolHandler(unittest.TestCase): - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def setUp(self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self._conn = mock_fromfd.return_value @@ -1027,7 +1019,6 @@ def setUp(self, self.mock_selector = mock_selector self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() @mock.patch('proxy.TcpServerConnection') @@ -1141,14 +1132,12 @@ def test_proxy_connection_failed(self) -> None: self.proxy.run_once() self.assertEqual(self.proxy.client.buffer, proxy.ProxyConnectionFailed.RESPONSE_PKT) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_proxy_authentication_failed( self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) config = proxy.ProtocolConfig( @@ -1158,7 +1147,6 @@ def test_proxy_authentication_failed( b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self._conn.recv.return_value = proxy.CRLF.join([ b'GET http://abhinavsingh.com HTTP/1.1', @@ -1170,15 +1158,13 @@ def test_proxy_authentication_failed( self.proxy.client.buffer, proxy.ProxyAuthenticationFailed.RESPONSE_PKT) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.TcpServerConnection') def test_authenticated_proxy_http_get( self, mock_server_connection: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) @@ -1194,7 +1180,6 @@ def test_authenticated_proxy_http_get( self.proxy = proxy.ProtocolHandler( self.fileno, addr=self._addr, config=config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() assert self.http_server_port is not None @@ -1221,15 +1206,13 @@ def test_authenticated_proxy_http_get( ]) self.assert_data_queued(mock_server_connection, server) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') @mock.patch('proxy.TcpServerConnection') def test_authenticated_proxy_http_tunnel( self, mock_server_connection: mock.Mock, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: server = mock_server_connection.return_value server.connect.return_value = True server.buffer_size.return_value = 0 @@ -1244,7 +1227,6 @@ def test_authenticated_proxy_http_tunnel( self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() assert self.http_server_port is not None @@ -1338,10 +1320,9 @@ def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: class TestWebServerPlugin(unittest.TestCase): - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, mock_os_close: mock.Mock) -> None: + def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self._conn = mock_fromfd.return_value @@ -1351,20 +1332,16 @@ def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, mock_os_close: b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_pac_file_served_from_disk( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: pac_file = 'proxy.pac' self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) self.init_and_make_pac_file_request(pac_file) - mock_os_close.assert_called_with(self.fileno) self.proxy.run_once() self.assertEqual( self.proxy.request.state, @@ -1377,17 +1354,14 @@ def test_pac_file_served_from_disk( }, body=f.read() )) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_pac_file_served_from_buffer( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector_for_client_read(mock_selector) pac_file_content = b'function FindProxyForURL(url, host) { return "PROXY localhost:8899; DIRECT"; }' self.init_and_make_pac_file_request(proxy.text_(pac_file_content)) - mock_os_close.assert_called_with(self.fileno) self.proxy.run_once() self.assertEqual( self.proxy.request.state, @@ -1399,12 +1373,10 @@ def test_pac_file_served_from_buffer( }, body=pac_file_content )) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_default_web_server_returns_404( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self._conn = mock_fromfd.return_value mock_selector.return_value.select.return_value = [( selectors.SelectorKey( @@ -1417,7 +1389,6 @@ def test_default_web_server_returns_404( b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self._conn.recv.return_value = proxy.CRLF.join([ b'GET /hello HTTP/1.1', @@ -1431,12 +1402,10 @@ def test_default_web_server_returns_404( self.proxy.client.buffer, proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_static_web_server_serves( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: # Setup a static directory static_server_dir = os.path.join(tempfile.gettempdir(), 'static') index_file_path = os.path.join(static_server_dir, 'index.html') @@ -1488,14 +1457,12 @@ def test_static_web_server_serves( body=html_file_content )) - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_static_web_server_serves_404( self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: self._conn = mock_fromfd.return_value self._conn.recv.return_value = proxy.build_http_request(b'GET', b'/not-found.html') @@ -1517,7 +1484,6 @@ def test_static_web_server_serves_404( self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self.proxy.run_once() @@ -1528,17 +1494,15 @@ def test_static_web_server_serves_404( self.assertEqual(self._conn.send.call_args[0][0], proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE) - @mock.patch('os.close') @mock.patch('socket.fromfd') def test_on_client_connection_called_on_teardown( - self, mock_fromfd: mock.Mock, mock_os_close: mock.Mock) -> None: + self, mock_fromfd: mock.Mock) -> None: config = proxy.ProtocolConfig() plugin = mock.MagicMock() config.plugins = {b'ProtocolHandlerPlugin': [plugin]} self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() plugin.assert_called() with mock.patch.object(self.proxy, 'run_once') as mock_run_once: @@ -1570,13 +1534,11 @@ def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: class TestHttpProxyPlugin(unittest.TestCase): - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def setUp(self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: self.mock_fromfd = mock_fromfd self.mock_selector = mock_selector @@ -1591,7 +1553,6 @@ def setUp(self, self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() def test_proxy_plugin_initialized(self) -> None: @@ -1646,13 +1607,11 @@ def test_proxy_plugin_before_upstream_connection_can_teardown( class TestHttpProxyPluginExamples(unittest.TestCase): - @mock.patch('os.close') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def setUp(self, mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_selector: mock.Mock) -> None: self.fileno = 10 self._addr = ('127.0.0.1', 54382) self.config = proxy.ProtocolConfig() @@ -1670,7 +1629,6 @@ def setUp(self, self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() @mock.patch('proxy.TcpServerConnection') @@ -1871,7 +1829,6 @@ def closed() -> bool: class TestHttpProxyTlsInterception(unittest.TestCase): - @mock.patch('os.close') @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') @mock.patch('proxy.TcpServerConnection') @@ -1885,8 +1842,7 @@ def test_e2e( mock_popen: mock.Mock, mock_server_conn: mock.Mock, mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_ssl_wrap: mock.Mock) -> None: host, port = uuid.uuid4().hex, 443 netloc = '{0}:{1}'.format(host, port) @@ -1926,7 +1882,6 @@ def mock_connection() -> Any: self._conn = mock_fromfd.return_value self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self.plugin.assert_called() @@ -2005,7 +1960,6 @@ def mock_connection() -> Any: class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): - @mock.patch('os.close') @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') @mock.patch('proxy.TcpServerConnection') @@ -2018,8 +1972,7 @@ def setUp(self, mock_popen: mock.Mock, mock_server_conn: mock.Mock, mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock, - mock_os_close: mock.Mock) -> None: + mock_ssl_wrap: mock.Mock) -> None: self.mock_fromfd = mock_fromfd self.mock_selector = mock_selector self.mock_popen = mock_popen @@ -2045,7 +1998,6 @@ def setUp(self, mock_fromfd.return_value = self._conn self.proxy = proxy.ProtocolHandler( self.fileno, self._addr, config=self.config) - mock_os_close.assert_called_with(self.fileno) self.proxy.initialize() self.server = self.mock_server_conn.return_value From 57315289ae9db93277880bae11b65dcb2d7124d8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 27 Oct 2019 05:38:22 +0200 Subject: [PATCH 013/107] Update pytest from 5.2.1 to 5.2.2 (#142) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 21b5ab736f..1ad6f2d5cf 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,7 +1,7 @@ python-coveralls==2.9.3 coverage==4.5.4 flake8==3.7.8 -pytest==5.2.1 +pytest==5.2.2 autopep8==1.4.4 mypy==0.730 py-spy==0.2.2 From 5db42b349e2bede23d18b2a56acc5083ac113d40 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 28 Oct 2019 04:26:20 +0200 Subject: [PATCH 014/107] Update setuptools from 41.4.0 to 41.5.0 (#145) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index 2f5418c9f8..01a6a995d0 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==2.0.0 wheel==0.33.6 -setuptools==41.4.0 +setuptools==41.5.0 From 52275f0588e721fccfb3dcd0a8c6dfa100f26698 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 28 Oct 2019 21:30:53 +0200 Subject: [PATCH 015/107] Update typing-extensions from 3.7.4 to 3.7.4.1 (#147) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c05a7ea71b..ad2ed38003 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -typing-extensions==3.7.4 +typing-extensions==3.7.4.1 From e7e9e14e7504e1ec9bfb48d8328a847da6b93517 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 28 Oct 2019 22:12:45 +0200 Subject: [PATCH 016/107] Update flake8 from 3.7.8 to 3.7.9 (#148) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 1ad6f2d5cf..ca32b8936d 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,6 +1,6 @@ python-coveralls==2.9.3 coverage==4.5.4 -flake8==3.7.8 +flake8==3.7.9 pytest==5.2.2 autopep8==1.4.4 mypy==0.730 From 521a49ffd1e5af88377418719e6f2dadc7494600 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 28 Oct 2019 22:28:46 +0200 Subject: [PATCH 017/107] Update setuptools from 41.5.0 to 41.5.1 (#149) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index 01a6a995d0..6b145d61ce 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==2.0.0 wheel==0.33.6 -setuptools==41.5.0 +setuptools==41.5.1 From 3b2b2e5dd5595575ed87a64948e61c399c2425fd Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 28 Oct 2019 23:43:46 +0200 Subject: [PATCH 018/107] Update py-spy from 0.2.2 to 0.3.0 (#144) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index ca32b8936d..67eb4da72b 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -4,4 +4,4 @@ flake8==3.7.9 pytest==5.2.2 autopep8==1.4.4 mypy==0.730 -py-spy==0.2.2 +py-spy==0.3.0 From e14548252ca60f754368587a605547be6fd001c6 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 28 Oct 2019 14:57:33 -0700 Subject: [PATCH 019/107] Proxy.py Dashboard (#141) * Remove redundant variables * Initialize frontend dashboard app (written in typescript) * Add a WebsocketFrame.text method to quickly build a text frame raw packet, also close connection for static file serving, atleast Google Chrome seems to hang up instead of closing the connection * Add read_and_build_static_file_response method for reusability in plugins * teardown websocket connection when opcode CONNECTION_CLOSE is received * First draft of proxy.py dashboard * Remove uglify, obfuscator is superb enough * Correct generic V * First draft of dashboard * ProtocolConfig is now Flags * First big refactor toward no-single-file-module * Working tests * Update dashboard for refactored imports * Remove proxy.py as now we can just call python -m proxy -h * Fix setup.py for refactored code * Banner update * Lint check * Fix dashboard static serving and no UNDER_TEST constant necessary * Add support for plugin imports when specified in path/to/module.MyPlugin * Update README with instructions to run proxy.py after refactor * Move dashboard under /dashboard path * Rename to devtools.ts * remove unused * Update github workflow for new directory structure * Update test command too * Fix coverage generation * *.py is an invalid syntax on windows * No * on windows * Enable execution via github zip downloads * Github Zip downloads cannot be executed as Github puts project under a folder named after Github project, this breaks python interpreter expectation of finding a __main__.py in the root directory * Forget zip runs for now * Initialize ProxyDashboard on page load rather than within typescript i.e. on script load * Enforce eslint with standard style * Add .editorconfig to make editor compatible with various style requirements (Makefile, Typescript, Python) * Remove extra empty line * Add ability to pass headers with HttpRequestRejected exception, also remove proxy agent header for HttpRequestRejected * Add ability to pass headers with HttpRequestRejected exception, also remove proxy agent header for HttpRequestRejected * Fix tests * Move common code under common sub-module * Move flags under common module * Move acceptor under core * Move connection under core submodule * Move chunk_parser under http * Move http_parser as http/parser * Move http_methods as http/methods * Move http_proxy as http/proxy * Move web_server as http/server * Move status_codes as http/codes * move websocket as http/websocket * Move exception under http/exception, also move http/proxy exceptions under http/exceptions * move protocol_handler as http/handler * move devtools as http/devtools * Move version under common/version * Lifecycle if now core Event * autopep8 * Add core event queue * Register / unregister handler * Enable inspection support for frontend dashboard * Dont give an illusion of exception for HttpProtocolExceptions * Update readme for refactored codebase * DictQueueType everywhere * Move all websocket API related code under WebsocketApi class * Inspection enabled on tab switch. 1. Additionally now acceptors are assigned an int id. 2. Fix tests to match change in constructor. * Corresponding ends of the work queues can be closed immediately. Since work queues between AcceptorPool and Acceptor process is used only once, close corresponding ends asap instead of at shutdown. * No need of a manager for shared multiprocess Lock. This unnecessarily creates additional manager process. * Move threadless into its own module * Merge acceptor and acceptor_pool tests * Defer os.close * Change content display with tab clicks. Also ensure relay manager shutdown. * Remove --cov flags * Use right type for SyncManager * Ensure coverage again * Print help to discover flags, --cov certainly not available on Travis for some reason * Add pytest-cov to requirements-testing * Re-add windows on .travis also add changelog to readme * Use 3.7 and no pip upgrade since it fails on travis windows * Attempt to fix pip install on windows * Disable windows on travis, it fails and uses 3.8. Try reporting coverage from github actions * Move away from coveralls, use codecov * Codecov app installation either didnt work or token still needs to be passed * Remove travis CI * Use https://github.com/codecov/codecov-action for coverage uploads * Remove run codecov * Ha, codecov action only works on linux, what a mess * Add cookie.js though unable to use it with es5/es6 modules yet * Enable testing for python 3.8 also Build dashboard during testing * No python 3.8 on github actions yet * Autopep8 * Add separate workflows for library (python) and dashboard (node) app * Type jobs not job * Add checkout * Fix parsing node version * Fix dashboard build on windows * Show codecov instead of coveralls --- .editorconfig | 18 + .github/workflows/test-dashboard.yml | 30 + .../{testing.yml => test-library.yml} | 16 +- .gitignore | 3 +- .travis.yml | 17 - MANIFEST.in | 1 + Makefile | 37 +- Procfile | 2 - README.md | 214 +- .../.gitkeep => benchmark/__init__.py | 0 benchmark.py => benchmark/benchmark.py | 42 +- dashboard/.eslintrc.json | 23 + dashboard/dashboard.py | 162 + dashboard/package-lock.json | 3707 +++++++++++++++++ dashboard/package.json | 53 + dashboard/rollup.config.js | 39 + dashboard/spec/helpers/browser.js | 9 + dashboard/spec/support/jasmine.json | 11 + dashboard/src/devtools.ts | 37 + dashboard/src/proxy.css | 35 + dashboard/src/proxy.html | 147 + dashboard/src/proxy.ts | 217 + dashboard/static/bootstrap-4.3.1.min.css | 7 + dashboard/static/bootstrap-4.3.1.min.js | 7 + dashboard/static/font-awesome-4.7.0.min.css | 4 + .../static/fonts/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes .../static/fonts/fontawesome-webfont.woff2 | Bin 0 -> 77160 bytes dashboard/static/jquery-3.3.1.slim.min.js | 2 + .../static/js.cookie-v3.0.0-beta.0.min.js | 2 + dashboard/static/popper-1.14.7.min.js | 5 + dashboard/static/renderjson-b31d877.js | 189 + dashboard/test/test.ts | 7 + dashboard/tsconfig.json | 10 + helper/Procfile | 9 + .../chrome_with_proxy.sh | 4 +- fluentd.conf => helper/fluentd.conf | 7 + .../monitor_open_files.sh | 0 proxy.pac => helper/proxy.pac | 0 package-lock.json | 51 - package.json | 25 - plugin_examples.py | 305 -- plugin_examples/__init__.py | 9 + plugin_examples/cache_responses.py | 60 + plugin_examples/filter_by_upstream.py | 42 + plugin_examples/man_in_the_middle.py | 35 + plugin_examples/mock_rest_api.py | 87 + plugin_examples/modify_post_data.py | 46 + plugin_examples/redirect_to_custom_server.py | 44 + plugin_examples/shortlink.py | 83 + plugin_examples/web_server_route.py | 47 + proxy.js | 37 - proxy.py | 3243 -------------- proxy/__init__.py | 9 + proxy/__main__.py | 13 + proxy/common/__init__.py | 9 + proxy/common/constants.py | 78 + proxy/common/flags.py | 320 ++ proxy/common/types.py | 24 + proxy/common/utils.py | 199 + proxy/common/version.py | 11 + proxy/core/__init__.py | 9 + proxy/core/acceptor.py | 233 ++ proxy/core/connection.py | 129 + proxy/core/event.py | 142 + proxy/core/threadless.py | 242 ++ proxy/http/__init__.py | 9 + proxy/http/chunk_parser.py | 80 + proxy/http/codes.py | 46 + proxy/http/devtools.py | 314 ++ proxy/http/exception.py | 94 + proxy/http/handler.py | 414 ++ proxy/http/methods.py | 34 + proxy/http/parser.py | 262 ++ proxy/http/proxy.py | 458 ++ proxy/http/server.py | 309 ++ proxy/http/websocket.py | 265 ++ proxy/main.py | 212 + public/.gitkeep | 0 requirements-testing.txt | 2 + setup.py | 132 +- tests.py | 2387 ----------- tests/__init__.py | 14 + tests/test_acceptor.py | 147 + tests/test_chunk_parser.py | 89 + tests/test_connection.py | 118 + tests/test_http_parser.py | 520 +++ tests/test_http_proxy.py | 91 + tests/test_http_proxy_examples.py | 444 ++ tests/test_http_proxy_tls_interception.py | 169 + tests/test_http_request_rejected.py | 41 + tests/test_main.py | 229 + tests/test_protocol_handler.py | 349 ++ tests/test_set_open_file_limit.py | 53 + tests/test_text_bytes.py | 33 + tests/test_utils.py | 57 + tests/test_web_server.py | 243 ++ tests/test_websocket_client.py | 32 + tests/test_websocket_frame.py | 40 + 98 files changed, 12024 insertions(+), 6268 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/test-dashboard.yml rename .github/workflows/{testing.yml => test-library.yml} (59%) delete mode 100644 .travis.yml delete mode 100644 Procfile rename public/devtools/.gitkeep => benchmark/__init__.py (100%) rename benchmark.py => benchmark/benchmark.py (76%) create mode 100644 dashboard/.eslintrc.json create mode 100644 dashboard/dashboard.py create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/rollup.config.js create mode 100644 dashboard/spec/helpers/browser.js create mode 100644 dashboard/spec/support/jasmine.json create mode 100644 dashboard/src/devtools.ts create mode 100644 dashboard/src/proxy.css create mode 100644 dashboard/src/proxy.html create mode 100644 dashboard/src/proxy.ts create mode 100644 dashboard/static/bootstrap-4.3.1.min.css create mode 100644 dashboard/static/bootstrap-4.3.1.min.js create mode 100644 dashboard/static/font-awesome-4.7.0.min.css create mode 100644 dashboard/static/fonts/fontawesome-webfont.ttf create mode 100644 dashboard/static/fonts/fontawesome-webfont.woff2 create mode 100644 dashboard/static/jquery-3.3.1.slim.min.js create mode 100644 dashboard/static/js.cookie-v3.0.0-beta.0.min.js create mode 100644 dashboard/static/popper-1.14.7.min.js create mode 100644 dashboard/static/renderjson-b31d877.js create mode 100644 dashboard/test/test.ts create mode 100644 dashboard/tsconfig.json create mode 100644 helper/Procfile rename chrome_with_proxy.sh => helper/chrome_with_proxy.sh (89%) rename fluentd.conf => helper/fluentd.conf (77%) rename monitor_open_files.sh => helper/monitor_open_files.sh (100%) rename proxy.pac => helper/proxy.pac (100%) delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 plugin_examples.py create mode 100644 plugin_examples/__init__.py create mode 100644 plugin_examples/cache_responses.py create mode 100644 plugin_examples/filter_by_upstream.py create mode 100644 plugin_examples/man_in_the_middle.py create mode 100644 plugin_examples/mock_rest_api.py create mode 100644 plugin_examples/modify_post_data.py create mode 100644 plugin_examples/redirect_to_custom_server.py create mode 100644 plugin_examples/shortlink.py create mode 100644 plugin_examples/web_server_route.py delete mode 100644 proxy.js delete mode 100755 proxy.py create mode 100644 proxy/__init__.py create mode 100644 proxy/__main__.py create mode 100644 proxy/common/__init__.py create mode 100644 proxy/common/constants.py create mode 100644 proxy/common/flags.py create mode 100644 proxy/common/types.py create mode 100644 proxy/common/utils.py create mode 100644 proxy/common/version.py create mode 100644 proxy/core/__init__.py create mode 100644 proxy/core/acceptor.py create mode 100644 proxy/core/connection.py create mode 100644 proxy/core/event.py create mode 100644 proxy/core/threadless.py create mode 100644 proxy/http/__init__.py create mode 100644 proxy/http/chunk_parser.py create mode 100644 proxy/http/codes.py create mode 100644 proxy/http/devtools.py create mode 100644 proxy/http/exception.py create mode 100644 proxy/http/handler.py create mode 100644 proxy/http/methods.py create mode 100644 proxy/http/parser.py create mode 100644 proxy/http/proxy.py create mode 100644 proxy/http/server.py create mode 100644 proxy/http/websocket.py create mode 100755 proxy/main.py create mode 100644 public/.gitkeep delete mode 100644 tests.py create mode 100644 tests/__init__.py create mode 100644 tests/test_acceptor.py create mode 100644 tests/test_chunk_parser.py create mode 100644 tests/test_connection.py create mode 100644 tests/test_http_parser.py create mode 100644 tests/test_http_proxy.py create mode 100644 tests/test_http_proxy_examples.py create mode 100644 tests/test_http_proxy_tls_interception.py create mode 100644 tests/test_http_request_rejected.py create mode 100644 tests/test_main.py create mode 100644 tests/test_protocol_handler.py create mode 100644 tests/test_set_open_file_limit.py create mode 100644 tests/test_text_bytes.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_web_server.py create mode 100644 tests/test_websocket_client.py create mode 100644 tests/test_websocket_frame.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..662bdade94 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[Makefile] +indent_size = tab + +[*.py] +indent_style = space +indent_size = 4 + +[*.ts] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/test-dashboard.yml b/.github/workflows/test-dashboard.yml new file mode 100644 index 0000000000..8489ce1cc1 --- /dev/null +++ b/.github/workflows/test-dashboard.yml @@ -0,0 +1,30 @@ +name: Proxy.py Dashboard + +on: [push] + +jobs: + build: + runs-on: ${{ matrix.os }}-latest + name: Node ${{ matrix.node }} on ${{ matrix.os }} + strategy: + matrix: + os: [macOS, ubuntu, windows] + node: [10.x] + max-parallel: 4 + fail-fast: false + steps: + - uses: actions/checkout@v1 + - name: Setup Node + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node }} + - name: Install Dependencies + run: | + cd dashboard + npm install + cd .. + - name: Build Dashboard + run: | + cd dashboard + npm run build + cd .. diff --git a/.github/workflows/testing.yml b/.github/workflows/test-library.yml similarity index 59% rename from .github/workflows/testing.yml rename to .github/workflows/test-library.yml index 11cca3215a..cc617014ee 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/test-library.yml @@ -1,4 +1,4 @@ -name: Proxy.py +name: Proxy.py Library on: [push] @@ -18,17 +18,17 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python }}-dev - architecture: x64 - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-testing.txt - name: Quality Check run: | - # The GitHub editor is 127 chars wide - # W504 screams for line break after binary operators - flake8 --ignore=W504 --max-line-length=127 proxy.py plugin_examples.py tests.py setup.py benchmark.py - # mypy compliance check - mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py setup.py benchmark.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py - name: Run Tests - run: pytest tests.py + run: pytest --cov=proxy tests/ + - name: Upload coverage to Codecov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: codecov diff --git a/.gitignore b/.gitignore index eaabbb272a..25ef618ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ -.coverage +.coverage* .idea .vscode .project .pydevproject .settings .mypy_cache +coverage.xml node_modules venv cover diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 809068010e..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: python -env: - - TESTING_ON_TRAVIS=1 -matrix: - include: - - name: "Python 3.7 on Xenial Linux" - python: 3.7 - - name: "Python 3.7 on macOS" - os: osx - osx_image: xcode11 - language: shell - python: 3.7 -install: - - pip3 install -r requirements-testing.txt -script: python3 -m coverage run --source=proxy tests.py || python -m coverage run --source=proxy tests.py -after_success: - - coveralls diff --git a/MANIFEST.in b/MANIFEST.in index c1a7121c1b..7b741275f0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include LICENSE include README.md +include requirements.txt diff --git a/Makefile b/Makefile index 981bc25b75..1beefd5a27 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := /bin/bash NS ?= abhinavsingh IMAGE_NAME ?= proxy.py -VERSION ?= v$(shell python proxy.py --version) +VERSION ?= v$(shell python -m proxy --version) LATEST_TAG := $(NS)/$(IMAGE_NAME):latest IMAGE_TAG := $(NS)/$(IMAGE_NAME):$(VERSION) @@ -15,7 +15,7 @@ CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem .PHONY: all clean test package test-release release coverage lint autopep8 .PHONY: container run-container release-container https-certificates ca-certificates -.PHONY: profile +.PHONY: profile dashboard clean-dashboard all: clean test @@ -24,10 +24,14 @@ clean: find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + rm -f .coverage - rm -rf htmlcov dist build .pytest_cache proxy.py.egg-info + rm -rf htmlcov + rm -rf dist + rm -rf build + rm -rf proxy.py.egg-info + rm -rf .pytest_cache -test: - python -m unittest tests +test: lint + python -m unittest tests/*.py package: clean python setup.py sdist bdist_wheel @@ -39,18 +43,21 @@ release: package twine upload dist/* coverage: - coverage3 run --source=proxy,plugin_examples tests.py - coverage3 html + pytest --cov=proxy --cov-report=html tests/ open htmlcov/index.html lint: - flake8 --ignore=W504 --max-line-length=127 proxy.py plugin_examples.py tests.py setup.py benchmark.py - mypy --strict --ignore-missing-imports proxy.py plugin_examples.py tests.py setup.py benchmark.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py autopep8: - autopep8 --recursive --in-place --aggressive proxy.py - autopep8 --recursive --in-place --aggressive tests.py - autopep8 --recursive --in-place --aggressive plugin_examples.py + autopep8 --recursive --in-place --aggressive proxy/*.py + autopep8 --recursive --in-place --aggressive proxy/*/*.py + autopep8 --recursive --in-place --aggressive tests/*.py + autopep8 --recursive --in-place --aggressive plugin_examples/*.py + autopep8 --recursive --in-place --aggressive benchmark/*.py + autopep8 --recursive --in-place --aggressive dashboard/*.py + autopep8 --recursive --in-place --aggressive setup.py container: docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . @@ -79,3 +86,9 @@ ca-certificates: profile: sudo py-spy -F -f profile.svg -d 3600 proxy.py + +dashboard: + pushd dashboard && npm run build && popd + +clean-dashboard: + rm -rf public/dashboard diff --git a/Procfile b/Procfile deleted file mode 100644 index 4c58bb8cf5..0000000000 --- a/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -# See https://devcenter.heroku.com/articles/procfile -web: python3 proxy.py --hostname 0.0.0.0 --port $PORT diff --git a/README.md b/README.md index 3c23e58647..6602687067 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Docker Pulls](https://img.shields.io/docker/pulls/abhinavsingh/proxy.py?color=green)](https://hub.docker.com/r/abhinavsingh/proxy.py) [![Build Status](https://travis-ci.org/abhinavsingh/proxy.py.svg?branch=develop)](https://travis-ci.org/abhinavsingh/proxy.py/) [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) -[![Coverage](https://coveralls.io/repos/github/abhinavsingh/proxy.py/badge.svg?branch=develop)](https://coveralls.io/github/abhinavsingh/proxy.py?branch=develop) +[![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) [![Tested With MacOS](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) [![Tested With Ubuntu](https://img.shields.io/static/v1?label=tested%20with&message=Ubuntu%20%F0%9F%96%A5&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) @@ -34,14 +34,16 @@ Table of Contents * [Stable version](#stable-version) * [Development version](#development-version) * [Start proxy.py](#start-proxypy) - * [Command Line](#command-line) + * [From command line when installed using PIP](#from-command-line-when-installed-using-pip) + * [From command line using repo source](#from-command-line-using-repo-source) * [Docker Image](#docker-image) * [Stable version](#stable-version-from-docker-hub) * [Development version](#build-development-version-locally) + * [Customize Startup Flags](#customize-startup-flags) * [Plugin Examples](#plugin-examples) * [ShortLinkPlugin](#shortlinkplugin) * [ModifyPostDataPlugin](#modifypostdataplugin) - * [ProposedRestApiPlugin](#proposedrestapiplugin) + * [MockRestApiPlugin](#mockrestapiplugin) * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) * [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin) * [CacheResponsesPlugin](#cacheresponsesplugin) @@ -61,17 +63,20 @@ Table of Contents * [proxy.WebsocketClient](#proxywebsocketclient) * [Embed proxy.py](#embed-proxypy) * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) + * [Start proxy.py from repo source](#start-proxypy-from-repo-source) * [Everything is a plugin](#everything-is-a-plugin) * [Internal Architecture](#internal-architecture) * [Internal Documentation](#internal-documentation) * [Sending a Pull Request](#sending-a-pull-request) * [Frequently Asked Questions](#frequently-asked-questions) + * [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax) * [Unable to connect with proxy.py from remote host](#unable-to-connect-with-proxypy-from-remote-host) * [Basic auth not working with a browser](#basic-auth-not-working-with-a-browser) * [Docker image not working on MacOS](#docker-image-not-working-on-macos) * [Unable to load custom plugins](#unable-to-load-custom-plugins) * [ValueError: filedescriptor out of range in select](#valueerror-filedescriptor-out-of-range-in-select) * [Flags](#flags) +* [Changelog](#changelog) Features ======== @@ -100,7 +105,6 @@ Features 0.022 [332] |■ ``` - Lightweight - - Distributed as a single file module `~100KB` - Uses only `~5-20MB` RAM - No external dependency other than standard Python library - Programmable @@ -108,6 +112,10 @@ Features - Customize proxy and http routing via [plugins](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples.py) - Enable plugin using command line option e.g. `--plugins plugin_examples.CacheResponsesPlugin` - Plugin API is currently in development state, expect breaking changes. +- Realtime Dashboard + - Optionally enable bundled dashboard. Available at `http://localhost:8899/dashboard`. + - Inspect, Monitor, Control and Configure `proxy.py` at runtime. + - Extend dashboard using plugins. - Secure - Enable end-to-end encryption between clients and `proxy.py` using TLS - See [End-to-End Encryption](#end-to-end-encryption) @@ -138,38 +146,32 @@ or from GitHub `master` branch $ pip install git+https://github.com/abhinavsingh/proxy.py.git@master -or simply `wget` it: - - $ wget -q https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py - -or download from here [proxy.py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/master/proxy.py) - ## Development version $ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop -For `Docker` usage see [Docker Image](#docker-image). +For `Docker` installation see [Docker Image](#docker-image). Start proxy.py ============== -## Command line +## From command line when installed using PIP -Simply type `proxy.py` on command line to start it with default configuration. +Simply type `proxy` on command line to start it with default configuration. ``` -$ proxy.py -...[redacted]... - Loaded plugin +$ proxy +...[redacted]... - Loaded plugin proxy.http_proxy.HttpProxyPlugin ...[redacted]... - Starting 8 workers ...[redacted]... - Started server on ::1:8899 ``` Things to notice from above logs: -- `Loaded plugin` - `proxy.py` will load `HttpProxyPlugin` by default. It adds `http(s)` - proxy server capabilities to `proxy.py` +- `Loaded plugin` - `proxy.py` will load `proxy.http.proxy.HttpProxyPlugin` by default. + As name suggests, this core plugin adds `http(s)` proxy server capabilities to `proxy.py` -- `Started N workers` - Use `--num-workers` flag to customize number of `Worker` processes. +- `Started N workers` - Use `--num-workers` flag to customize number of worker processes. By default, `proxy.py` will start as many workers as there are CPU cores on the machine. - `Started server on ::1:8899` - By default, `proxy.py` listens on IPv6 `::1`, which @@ -184,9 +186,9 @@ All the logs above are `INFO` level logs, default `--log-level` for `proxy.py`. Lets start `proxy.py` with `DEBUG` level logging: ``` -$ proxy.py --log-level d +$ proxy --log-level d ...[redacted]... - Open file descriptor soft limit set to 1024 -...[redacted]... - Loaded plugin +...[redacted]... - Loaded plugin proxy.http_proxy.HttpProxyPlugin ...[redacted]... - Started 8 workers ...[redacted]... - Started server on ::1:8899 ``` @@ -199,6 +201,24 @@ As we can see, before starting up: See [flags](#flags) for full list of available configuration options. +## From command line using repo source + +When `proxy.py` is installed using `pip`, +a binary file named `proxy` is added under the `bin` folder. + +If you are trying to run `proxy.py` from source code, +there is no binary file named `proxy` in the source code. +To start `proxy.py` from source code, use: + +``` +$ git clone https://github.com/abhinavsingh/proxy.py.git +$ cd proxy.py +$ python -m proxy +``` + +Also see [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) +if you plan to work with `proxy.py` source code. + ## Docker image #### Stable Version from Docker Hub @@ -210,7 +230,9 @@ See [flags](#flags) for full list of available configuration options. $ git clone https://github.com/abhinavsingh/proxy.py.git $ cd proxy.py $ make container - $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:v$(./proxy.py -v) + $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest + +### Customize startup flags By default `docker` binary is started with IPv4 networking flags: @@ -230,7 +252,7 @@ For example, to check `proxy.py` version within Docker image: Plugin Examples =============== -See [plugin_examples.py](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples.py) for full code. +See [plugin_examples](https://github.com/abhinavsingh/proxy.py/tree/develop/plugin_examples) for full code. All the examples below also works with `https` traffic but require additional flags and certificate generation. See [TLS Interception](#tls-interception). @@ -242,8 +264,8 @@ Add support for short links in your favorite browsers / applications. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.ShortLinkPlugin +$ proxy \ + --plugins plugin_examples/shortlink.ShortLinkPlugin ``` Now you can speed up your daily browsing experience by visiting your @@ -271,8 +293,8 @@ Modifies POST request body before sending request to upstream server. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.ModifyPostDataPlugin +$ proxy \ + --plugins plugin_examples/modify_post_data.ModifyPostDataPlugin ``` By default plugin replaces POST body content with hardcoded `b'{"key": "modified"}'` @@ -305,7 +327,7 @@ Note following from the response above: 1. POST data was modified `"data": "{\"key\": \"modified\"}"`. Original `curl` command data was `{"key": "value"}`. -2. Our `curl` command didn't add any `Content-Type` header, +2. Our `curl` command did not add any `Content-Type` header, but our plugin did add one `"Content-Type": "application/json"`. Same can also be verified by looking at `json` field in the output above: ``` @@ -316,7 +338,7 @@ Note following from the response above: 3. Our plugin also added a `Content-Length` header to match length of modified body. -## ProposedRestApiPlugin +## MockRestApiPlugin Mock responses for your server REST API. Use to test and develop client side applications @@ -325,8 +347,8 @@ without need of an actual upstream REST API server. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.ProposedRestApiPlugin +$ proxy \ + --plugins plugin_examples/mock_rest_api.ProposedRestApiPlugin ``` Verify mock API response using `curl -x localhost:8899 http://api.example.com/v1/users/` @@ -356,9 +378,9 @@ also running on `8899` port. Start `proxy.py` and enable inbuilt web server: ``` -$ proxy.py \ +$ proxy \ --enable-web-server \ - --plugins plugin_examples.RedirectToCustomServerPlugin + --plugins plugin_examples/redirect_to_custom_server.RedirectToCustomServerPlugin ``` Verify using `curl -v -x localhost:8899 http://google.com` @@ -390,8 +412,8 @@ By default, plugin drops traffic for `google.com` and `www.google.com`. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.FilterByUpstreamHostPlugin +$ proxy \ + --plugins plugin_examples/filter_by_upstream.FilterByUpstreamHostPlugin ``` Verify using `curl -v -x localhost:8899 http://google.com`: @@ -410,7 +432,7 @@ Above `418 I'm a tea pot` is sent by our plugin. Verify the same by inspecting logs for `proxy.py`: ``` -2019-09-24 19:21:37,893 - ERROR - pid:50074 - handle_readables:1347 - ProtocolException type raised +2019-09-24 19:21:37,893 - ERROR - pid:50074 - handle_readables:1347 - HttpProtocolException type raised Traceback (most recent call last): ... [redacted] ... 2019-09-24 19:21:37,897 - INFO - pid:50074 - access_log:1157 - ::1:49911 - GET None:None/ - None None - 0 bytes @@ -423,8 +445,8 @@ Caches Upstream Server Responses. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.CacheResponsesPlugin +$ proxy \ + --plugins plugin_examples/cache_responses.CacheResponsesPlugin ``` Verify using `curl -v -x localhost:8899 http://httpbin.org/get`: @@ -499,8 +521,8 @@ Modifies upstream server responses. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.ManInTheMiddlePlugin +$ proxy \ + --plugins plugin_examples/man_in_the_middle.ManInTheMiddlePlugin ``` Verify using `curl -v -x localhost:8899 http://google.com`: @@ -547,7 +569,7 @@ make https-certificates Start `proxy.py` as: ``` -$ proxy.py \ +$ proxy \ --cert-file https-cert.pem \ --key-file https-key.pem ``` @@ -570,7 +592,7 @@ Verify using `curl -x https://localhost:8899 --proxy-cacert https-cert.pem https TLS Interception ================= -By default, `proxy.py` doesn't decrypt `https` traffic between client and server. +By default, `proxy.py` will not decrypt `https` traffic between client and server. To enable TLS interception first generate CA certificates: ``` @@ -581,8 +603,8 @@ Lets also enable `CacheResponsePlugin` so that we can verify decrypted response from the server. Start `proxy.py` as: ``` -$ proxy.py \ - --plugins plugin_examples.CacheResponsesPlugin \ +$ proxy \ + --plugins plugin_examples/cache_responses.CacheResponsesPlugin \ --ca-key-file ca-key.pem \ --ca-cert-file ca-cert.pem \ --ca-signing-key-file ca-signing-key.pem @@ -754,6 +776,16 @@ for all available classes and utility methods. Plugin Developer and Contributor Guide ====================================== +## Start proxy.py from repo source + +Contributors must start `proxy.py` from source to verify and develop new features / fixes. + +Start `proxy.py` as: + + $ git clone https://github.com/abhinavsingh/proxy.py.git + $ cd proxy.py + $ python -m proxy + ## Everything is a plugin As you might have guessed by now, in `proxy.py` everything is a plugin. @@ -767,30 +799,30 @@ As you might have guessed by now, in `proxy.py` everything is a plugin. Example, [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin). - We also enabled inbuilt web server using `--enable-web-server`. - Inbuilt web server implements `ProtocolHandlerPlugin` plugin. - See documentation of [ProtocolHandlerPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L793-L850) - for available lifecycle hooks. Use `ProtocolHandlerPlugin` to add + Inbuilt web server implements `HttpProtocolHandlerPlugin` plugin. + See documentation of [HttpProtocolHandlerPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L793-L850) + for available lifecycle hooks. Use `HttpProtocolHandlerPlugin` to add new features for http(s) clients. Example, [HttpWebServerPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1185-L1260). - There also is a `--disable-http-proxy` flag. It disables inbuilt proxy server. Use this flag with `--enable-web-server` flag to run `proxy.py` as a programmable http(s) server. [HttpProxyPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L941-L1182) - also implements `ProtocolHandlerPlugin`. + also implements `HttpProtocolHandlerPlugin`. ## Internal Architecture -- [ProtocolHandler](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1263-L1440) +- [HttpProtocolHandler](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1263-L1440) thread is started with the accepted [TcpClientConnection](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L230-L237). -`ProtocolHandler` is responsible for parsing incoming client request and invoking -`ProtocolHandlerPlugin` lifecycle hooks. +`HttpProtocolHandler` is responsible for parsing incoming client request and invoking +`HttpProtocolHandlerPlugin` lifecycle hooks. -- `HttpProxyPlugin` which implements `ProtocolHandlerPlugin` also has its own plugin +- `HttpProxyPlugin` which implements `HttpProtocolHandlerPlugin` also has its own plugin mechanism. Its responsibility is to establish connection between client and upstream [TcpServerConnection](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L204-L227) and invoke `HttpProxyBasePlugin` lifecycle hooks. -- `ProtocolHandler` threads are started by [Acceptor](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L424-L472) +- `HttpProtocolHandler` threads are started by [Acceptor](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L424-L472) processes. - `--num-workers` `Acceptor` processes are started by @@ -799,7 +831,7 @@ and invoke `HttpProxyBasePlugin` lifecycle hooks. - `AcceptorPool` listens on server socket and pass the handler to `Acceptor` processes. Workers are responsible for accepting new client connections and starting - `ProtocolHandler` thread. + `HttpProtocolHandler` thread. ## Sending a Pull Request @@ -829,55 +861,26 @@ Example: ``` $ pydoc3 proxy -CLASSES - abc.ABC(builtins.object) - HttpProxyBasePlugin - HttpWebServerBasePlugin - DevtoolsWebsocketPlugin - HttpWebServerPacFilePlugin - ProtocolHandlerPlugin - DevtoolsProtocolPlugin - HttpProxyPlugin - HttpWebServerPlugin - TcpConnection - TcpClientConnection - TcpServerConnection - WebsocketClient - ThreadlessWork - ProtocolHandler(threading.Thread, ThreadlessWork) - builtins.Exception(builtins.BaseException) - ProtocolException - HttpRequestRejected - ProxyAuthenticationFailed - ProxyConnectionFailed - TcpConnectionUninitializedException - builtins.object - AcceptorPool - ChunkParser - HttpParser - ProtocolConfig - WebsocketFrame - builtins.tuple(builtins.object) - ChunkParserStates - HttpMethods - HttpParserStates - HttpParserTypes - HttpProtocolTypes - HttpStatusCodes - TcpConnectionTypes - WebsocketOpcodes - contextlib.ContextDecorator(builtins.object) - socket_connection - multiprocessing.context.Process(multiprocessing.process.BaseProcess) - Acceptor - Threadless - threading.Thread(builtins.object) - ProtocolHandler(threading.Thread, ThreadlessWork) +PACKAGE CONTENTS + __main__ + common (package) + core (package) + http (package) + main + +FILE + /Users/abhinav/Dev/proxy.py/proxy/__init__.py ``` Frequently Asked Questions ========================== +## SyntaxError: invalid syntax + +Make sure you are using `Python 3`. Verify the version before running `proxy.py`: + +`$ python --version` + ## Unable to connect with proxy.py from remote host Make sure `proxy.py` is listening on correct network interface. @@ -1077,3 +1080,20 @@ optional arguments: Proxy.py not working? Report at: https://github.com/abhinavsingh/proxy.py/issues/new ``` + +Changelog +========= + +- `v2.x` + - No longer ~~a single file module~~. + - Added dashboard app. +- `v1.x` + - `Python3` only. + - Deprecated support for ~~Python 2.x~~. + - Added support for multi accept. + - Added plugin support. +- `v0.x` + - Single file. + - Single threaded server. + +For detailed changelog refer diff --git a/public/devtools/.gitkeep b/benchmark/__init__.py similarity index 100% rename from public/devtools/.gitkeep rename to benchmark/__init__.py diff --git a/benchmark.py b/benchmark/benchmark.py similarity index 76% rename from benchmark.py rename to benchmark/benchmark.py index 5ce54142e4..317d21b380 100755 --- a/benchmark.py +++ b/benchmark/benchmark.py @@ -14,7 +14,10 @@ import time from typing import List, Tuple -import proxy +from proxy.common.constants import __homepage__, DEFAULT_BUFFER_SIZE +from proxy.common.utils import build_http_request +from proxy.http.methods import httpMethods +from proxy.http.parser import httpParserStates, httpParserTypes, HttpParser DEFAULT_N = 1 @@ -27,13 +30,14 @@ def init_parser() -> argparse.ArgumentParser: 'keep-alive connections are opened. Over each opened ' 'connection multiple pipelined request / response ' 'packets are exchanged with proxy.py web server.', - epilog='Proxy.py not working? Report at: %s/issues/new' % proxy.__homepage__ + epilog='Proxy.py not working? Report at: %s/issues/new' % __homepage__ ) parser.add_argument( '--n', '-n', type=int, default=DEFAULT_N, - help='Default: ' + str(DEFAULT_N) + '. See description above for meaning of N.' + help='Default: ' + str(DEFAULT_N) + + '. See description above for meaning of N.' ) return parser @@ -42,7 +46,8 @@ class Benchmark: def __init__(self, n: int = DEFAULT_N) -> None: self.n = n - self.clients: List[Tuple[asyncio.StreamReader, asyncio.StreamWriter]] = [] + self.clients: List[Tuple[asyncio.StreamReader, + asyncio.StreamWriter]] = [] async def open_connections(self) -> None: for _ in range(self.n): @@ -53,18 +58,18 @@ async def open_connections(self) -> None: async def send(writer: asyncio.StreamWriter) -> None: try: while True: - writer.write(proxy.build_http_request( - proxy.httpMethods.GET, b'/' + writer.write(build_http_request( + httpMethods.GET, b'/' )) await asyncio.sleep(0.01) except KeyboardInterrupt: pass @staticmethod - def parse_pipeline_response(response: proxy.HttpParser, raw: bytes, counter: int = 0) -> \ - Tuple[proxy.HttpParser, int]: + def parse_pipeline_response(response: HttpParser, raw: bytes, counter: int = 0) -> \ + Tuple[HttpParser, int]: response.parse(raw) - if response.state != proxy.httpParserStates.COMPLETE: + if response.state != httpParserStates.COMPLETE: # Need more data return response, counter @@ -72,22 +77,25 @@ def parse_pipeline_response(response: proxy.HttpParser, raw: bytes, counter: int # No more buffer left to parse return response, counter + 1 - # For pipelined requests we may have pending buffer, try parse them as responses - pipelined_response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) - return Benchmark.parse_pipeline_response(pipelined_response, response.buffer, counter + 1) + # For pipelined requests we may have pending buffer, try parse them as + # responses + pipelined_response = HttpParser(httpParserTypes.RESPONSE_PARSER) + return Benchmark.parse_pipeline_response( + pipelined_response, response.buffer, counter + 1) @staticmethod async def recv(idd: int, reader: asyncio.StreamReader) -> None: print_every = 1000 last_print = time.time() num_completed_requests: int = 0 - response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + response = HttpParser(httpParserTypes.RESPONSE_PARSER) try: while True: - raw = await reader.read(proxy.DEFAULT_BUFFER_SIZE) - response, total_parsed = Benchmark.parse_pipeline_response(response, raw) - if response.state == proxy.httpParserStates.COMPLETE: - response = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) + raw = await reader.read(DEFAULT_BUFFER_SIZE) + response, total_parsed = Benchmark.parse_pipeline_response( + response, raw) + if response.state == httpParserStates.COMPLETE: + response = HttpParser(httpParserTypes.RESPONSE_PARSER) if total_parsed > 0: num_completed_requests += total_parsed # print('total parsed %d' % total_parsed) diff --git a/dashboard/.eslintrc.json b/dashboard/.eslintrc.json new file mode 100644 index 0000000000..1ad2585834 --- /dev/null +++ b/dashboard/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "es6": true + }, + "extends": [ + "standard" + ], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2018 + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + } +} diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py new file mode 100644 index 0000000000..1995de8b85 --- /dev/null +++ b/dashboard/dashboard.py @@ -0,0 +1,162 @@ +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import json +import queue +import logging +import threading +import multiprocessing +import uuid +from typing import List, Tuple, Optional, Any + +from proxy.http.server import HttpWebServerPlugin, HttpWebServerBasePlugin, httpProtocolTypes +from proxy.http.parser import HttpParser +from proxy.http.websocket import WebsocketFrame +from proxy.http.codes import httpStatusCodes +from proxy.common.utils import build_http_response, bytes_ +from proxy.common.types import DictQueueType +from proxy.core.connection import TcpClientConnection + +logger = logging.getLogger(__name__) + + +class ProxyDashboard(HttpWebServerBasePlugin): + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.inspection_enabled: bool = False + self.relay_thread: Optional[threading.Thread] = None + self.relay_shutdown: Optional[threading.Event] = None + self.relay_manager: Optional[multiprocessing.managers.SyncManager] = None + self.relay_channel: Optional[DictQueueType] = None + self.relay_sub_id: Optional[str] = None + + def routes(self) -> List[Tuple[int, bytes]]: + return [ + # Redirects to /dashboard/ + (httpProtocolTypes.HTTP, b'/dashboard'), + # Redirects to /dashboard/ + (httpProtocolTypes.HTTPS, b'/dashboard'), + # Redirects to /dashboard/ + (httpProtocolTypes.HTTP, b'/dashboard/proxy.html'), + # Redirects to /dashboard/ + (httpProtocolTypes.HTTPS, b'/dashboard/proxy.html'), + (httpProtocolTypes.HTTP, b'/dashboard/'), + (httpProtocolTypes.HTTPS, b'/dashboard/'), + (httpProtocolTypes.WEBSOCKET, b'/dashboard'), + ] + + def handle_request(self, request: HttpParser) -> None: + if request.path == b'/dashboard/': + self.client.queue( + HttpWebServerPlugin.read_and_build_static_file_response( + os.path.join(self.flags.static_server_dir, 'dashboard', 'proxy.html'))) + elif request.path in ( + b'/dashboard', + b'/dashboard/proxy.html'): + self.client.queue(build_http_response( + httpStatusCodes.PERMANENT_REDIRECT, reason=b'Permanent Redirect', + headers={ + b'Location': b'/dashboard/', + b'Content-Length': b'0', + b'Connection': b'close', + } + )) + + def on_websocket_open(self) -> None: + logger.info('app ws opened') + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + try: + assert frame.data + message = json.loads(frame.data) + except UnicodeDecodeError: + logger.error(frame.data) + logger.info(frame.opcode) + return + + if message['method'] == 'ping': + self.reply_pong(message['id']) + elif message['method'] == 'enable_inspection': + # inspection can only be enabled if --enable-events is used + if not self.flags.enable_events: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps( + {'id': message['id'], 'response': 'not enabled'}) + ) + ) + ) + else: + self.inspection_enabled = True + + self.relay_shutdown = threading.Event() + self.relay_manager = multiprocessing.Manager() + self.relay_channel = self.relay_manager.Queue() + self.relay_thread = threading.Thread( + target=self.relay_events, + args=(self.relay_shutdown, self.relay_channel, self.client)) + self.relay_thread.start() + + self.relay_sub_id = uuid.uuid4().hex + self.event_queue.subscribe( + self.relay_sub_id, self.relay_channel) + elif message['method'] == 'disable_inspection': + if self.inspection_enabled: + self.shutdown_relay() + self.inspection_enabled = False + else: + logger.info(frame.data) + logger.info(frame.opcode) + + def shutdown_relay(self) -> None: + assert self.relay_manager + assert self.relay_shutdown + assert self.relay_thread + + self.relay_shutdown.set() + self.relay_thread.join() + self.relay_manager.shutdown() + + self.relay_manager = None + self.relay_thread = None + self.relay_shutdown = None + self.relay_channel = None + self.relay_sub_id = None + + def on_websocket_close(self) -> None: + logger.info('app ws closed') + if self.inspection_enabled: + self.shutdown_relay() + + def reply_pong(self, idd: int) -> None: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps({'id': idd, 'response': 'pong'})))) + + @staticmethod + def relay_events( + shutdown: threading.Event, + channel: DictQueueType, + client: TcpClientConnection) -> None: + while not shutdown.is_set(): + try: + ev = channel.get(timeout=1) + client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(ev)))) + except queue.Empty: + pass + except EOFError: + break + except KeyboardInterrupt: + break diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000000..2bd8e08d51 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,3707 @@ +{ + "name": "proxy.py", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/runtime": { + "version": "7.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-rc.1.tgz", + "integrity": "sha512-Nifv2kwP/nwR39cAOasNxzjYfpeuf/ZbZNtQz5eYxWTC9yHARU9wItFnAwz1GTZ62MU+AtSjzZPMbLK5Q9hmbg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.12.0" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", + "integrity": "sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.3", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz", + "integrity": "sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz", + "integrity": "sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.3", + "fastq": "^1.6.0" + } + }, + "@types/eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", + "dev": true + }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, + "@types/fs-extra": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.0.1.tgz", + "integrity": "sha512-J00cVDALmi/hJOYsunyT52Hva5TnJeKP5yd1r+mH/ZU0mbYZflR0Z5kw5kITtKTRYMhm1JMClOFYdHnQszEvqw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", + "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/jasmine": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.4.4.tgz", + "integrity": "sha512-+/sHcTPyDS1JQacDRRRWb+vNrjBwnD+cKvTaWlxlJ/uOOFvzCkjOwNaqVjYMLfsjzNi0WtDH9RyReDXPG1Cdug==", + "dev": true + }, + "@types/jquery": { + "version": "3.3.31", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.31.tgz", + "integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, + "@types/js-cookie": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.4.tgz", + "integrity": "sha512-WTfSE1Eauak/Nrg6cA9FgPTFvVawejsai6zXoq0QYTQ3mxONeRtGhKxa7wMlUzWWmzrmTeV+rwLjHgsCntdrsA==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz", + "integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==", + "dev": true + }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, + "@types/node": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.11.1.tgz", + "integrity": "sha512-TJtwsqZ39pqcljJpajeoofYRfeZ7/I/OMUQ5pR4q5wOKf2ocrUvBAZUMhWsOvKx3dVc/aaV5GluBivt0sWqA5A==", + "dev": true + }, + "@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==", + "dev": true + }, + "@typescript-eslint/eslint-plugin": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.5.0.tgz", + "integrity": "sha512-ddrJZxp5ns1Lh5ofZQYk3P8RyvKfyz/VcRR4ZiJLHO/ljnQAO8YvTfj268+WJOOadn99mvDiqJA65+HAKoeSPA==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "2.5.0", + "eslint-utils": "^1.4.2", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^2.0.1", + "tsutils": "^3.17.1" + } + }, + "@typescript-eslint/experimental-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.5.0.tgz", + "integrity": "sha512-UgcQGE0GKJVChyRuN1CWqDW8Pnu7+mVst0aWrhiyuUD1J9c+h8woBdT4XddCvhcXDodTDVIfE3DzGHVjp7tUeQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/typescript-estree": "2.5.0", + "eslint-scope": "^5.0.0" + } + }, + "@typescript-eslint/parser": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.5.0.tgz", + "integrity": "sha512-9UBMiAwIDWSl79UyogaBdj3hidzv6exjKUx60OuZuFnJf56tq/UMpdPcX09YmGqE8f4AnAueYtBxV8IcAT3jdQ==", + "dev": true, + "requires": { + "@types/eslint-visitor-keys": "^1.0.0", + "@typescript-eslint/experimental-utils": "2.5.0", + "@typescript-eslint/typescript-estree": "2.5.0", + "eslint-visitor-keys": "^1.1.0" + } + }, + "@typescript-eslint/typescript-estree": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.5.0.tgz", + "integrity": "sha512-AXURyF8NcA3IsnbjNX1v9qbwa0dDoY9YPcKYR2utvMHoUcu3636zrz0gRWtVAyxbPCkhyKuGg6WZIyi2Fc79CA==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "glob": "^7.1.4", + "is-glob": "^4.0.1", + "lodash.unescape": "4.0.1", + "semver": "^6.3.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "abab": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.2.tgz", + "integrity": "sha512-2scffjvioEmNz0OyDSLGWDfKCVwaKc6l9Pm9kOIREU13ClXZvHpg/nRL5xyjSSSLhOnXqft2HpsAzNEEA8cFFg==", + "dev": true + }, + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "dev": true + }, + "acorn-globals": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz", + "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==", + "dev": true, + "requires": { + "acorn": "^6.0.1", + "acorn-walk": "^6.0.1" + }, + "dependencies": { + "acorn": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz", + "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==", + "dev": true + } + } + }, + "acorn-jsx": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-4.1.1.tgz", + "integrity": "sha512-JY+iV6r+cO21KtntVvFkD+iqjtdpRUpGqKWgfkCdZq1R+kbreEl8EcdcJR4SmiIgsIQT33s6QzheQ9a275Q8xw==", + "dev": true, + "requires": { + "acorn": "^5.0.3" + } + }, + "acorn-walk": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", + "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", + "dev": true + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true, + "optional": true + }, + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=", + "dev": true + }, + "array-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", + "dev": true + }, + "array-includes": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.0.3.tgz", + "integrity": "sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.7.0" + } + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "babel-polyfill": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.23.0.tgz", + "integrity": "sha1-g2TKYt+Or7gwSZ9pkXdGbDsDSZ0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "core-js": "^2.4.0", + "regenerator-runtime": "^0.10.0" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=", + "dev": true + } + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + } + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-process-hrtime": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz", + "integrity": "sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw==", + "dev": true + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chance": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/chance/-/chance-1.0.16.tgz", + "integrity": "sha512-2bgDHH5bVfAXH05SPtjqrsASzZ7h90yCuYT2z4mkYpxxYvJXiIydBFzVieVHZx7wLH1Ag2Azaaej2/zA1XUrNQ==", + "dev": true + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", + "dev": true + }, + "chrome-devtools-frontend": { + "version": "1.0.706688", + "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.706688.tgz", + "integrity": "sha512-2zOpA/bTouVt9/hhLQbaNMPVHHJH+dW1OkR/PFagCqYp2+mrPgXr1vi44BdzMOEw+qIhj7x5wg2mZFK02/30MQ==", + "dev": true + }, + "class-validator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.9.1.tgz", + "integrity": "sha512-3wApflrd3ywVZyx4jaasGoFt8pmo4aGLPPAEKCKCsTRWVGPilahD88q3jQjRQwja50rl9a7rsP5LAxJYwGK8/Q==", + "dev": true, + "requires": { + "google-libphonenumber": "^3.1.6", + "validator": "10.4.0" + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "colorette": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.1.0.tgz", + "integrity": "sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg==", + "dev": true + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", + "dev": true + }, + "core-js": { + "version": "2.6.10", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.10.tgz", + "integrity": "sha512-I39t74+4t+zau64EN1fE5v2W31Adtc/REhzWN+gWRRXg6WH5qAsZm62DHpQ1+Yhe4047T55jvzz7MUqF/dBBlA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c=", + "dev": true + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", + "dev": true + }, + "cssom": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.1.tgz", + "integrity": "sha512-6Aajq0XmukE7HdXUU6IoSWuH1H6gH9z6qmagsstTiN7cW2FNTsb+J2Chs+ufPgZCsV/yo8oaEudQLrb9dGxSVQ==", + "dev": true + }, + "cssstyle": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.0.0.tgz", + "integrity": "sha512-QXSAu2WBsSRXCPjvI43Y40m6fMevvyRm8JVAuF9ksQz5jha4pWP1wpaK7Yu5oLFc6+XAY+hj8YhefyXcBB53gg==", + "dev": true, + "requires": { + "cssom": "~0.3.6" + }, + "dependencies": { + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + } + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "data-urls": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz", + "integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "whatwg-mimetype": "^2.2.0", + "whatwg-url": "^7.0.0" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + }, + "dependencies": { + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + } + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "dev": true, + "requires": { + "webidl-conversions": "^4.0.2" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ecstatic": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/ecstatic/-/ecstatic-3.3.2.tgz", + "integrity": "sha512-fLf9l1hnwrHI2xn9mEDT7KIi22UDqA2jaCwyCbSUJh9a1V+LEUSL/JO/6TIz/QyuBURWUHrFL5Kg2TtO1bkkog==", + "dev": true, + "requires": { + "he": "^1.1.1", + "mime": "^1.6.0", + "minimist": "^1.1.0", + "url-join": "^2.0.5" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "dev": true, + "requires": { + "iconv-lite": "~0.4.13" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.0.tgz", + "integrity": "sha512-xdQnfykZ9JMEiasTAJZJdMWCQ1Vm00NBw79/AWi7ELfZuuPCSOMDZbT9mkOfSctVtfhb+sAAzrm+j//GjjLHLg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.0", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.0", + "is-callable": "^1.1.4", + "is-regex": "^1.0.4", + "object-inspect": "^1.6.0", + "object-keys": "^1.1.1", + "string.prototype.trimleft": "^2.1.0", + "string.prototype.trimright": "^2.1.0" + } + }, + "es-to-primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", + "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen-wallaby": { + "version": "1.6.19", + "resolved": "https://registry.npmjs.org/escodegen-wallaby/-/escodegen-wallaby-1.6.19.tgz", + "integrity": "sha512-q+JGvR5+NR+EJBLnGAevCk5PIiIhPkUFCvcm6w9MWYtm8sv4FdGUsgzWsY6At/YHkgMyA366sjphA/JTNC8CeQ==", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + }, + "dependencies": { + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "eslint": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.5.1.tgz", + "integrity": "sha512-32h99BoLYStT1iq1v2P9uwpyznQ4M2jRiFB6acitKz52Gqn+vPaMDUTB1bYi1WN4Nquj2w+t+bimYUG83DC55A==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.10.0", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^5.0.0", + "eslint-utils": "^1.4.2", + "eslint-visitor-keys": "^1.1.0", + "espree": "^6.1.1", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^11.7.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^6.4.1", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.14", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^6.1.2", + "strip-ansi": "^5.2.0", + "strip-json-comments": "^3.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "acorn": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", + "dev": true + }, + "acorn-jsx": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.1.0.tgz", + "integrity": "sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==", + "dev": true + }, + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "espree": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-6.1.2.tgz", + "integrity": "sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==", + "dev": true, + "requires": { + "acorn": "^7.1.0", + "acorn-jsx": "^5.1.0", + "eslint-visitor-keys": "^1.1.0" + } + }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "inquirer": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz", + "integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.12", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "eslint-config-standard": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-14.1.0.tgz", + "integrity": "sha512-EF6XkrrGVbvv8hL/kYa/m6vnvmUT+K82pJJc4JJVMM6+Qgqh0pnwprSxdduDLB9p/7bIxD+YV5O0wfb8lmcPbA==", + "dev": true + }, + "eslint-import-resolver-node": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz", + "integrity": "sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-module-utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.4.1.tgz", + "integrity": "sha512-H6DOj+ejw7Tesdgbfs4jeS4YMFrT8uI8xwd1gtQqXssaR0EQ26L+2O/w6wkYFy2MymON0fTwHmXBvvfLNZVZEw==", + "dev": true, + "requires": { + "debug": "^2.6.8", + "pkg-dir": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-plugin-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-2.0.0.tgz", + "integrity": "sha512-f6fceVtg27BR02EYnBhgWLFQfK6bN4Ll0nQFrBHOlCsAyxeZkn0NHns5O0YZOPrV1B3ramd6cgFwaoFLcSkwEQ==", + "dev": true, + "requires": { + "eslint-utils": "^1.4.2", + "regexpp": "^3.0.0" + }, + "dependencies": { + "regexpp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.0.0.tgz", + "integrity": "sha512-Z+hNr7RAVWxznLPuA7DIh8UNX1j9CDrUQxskw9IrBE1Dxue2lyXT+shqEIeLUjrokxIP8CMy1WkjgG3rTsd5/g==", + "dev": true + } + } + }, + "eslint-plugin-import": { + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.18.2.tgz", + "integrity": "sha512-5ohpsHAiUBRNaBWAF08izwUGlbrJoJJ+W9/TBwsGoR1MnlgfwMIKrFeSjWbt6moabiXW9xNvtFz+97KHRfI4HQ==", + "dev": true, + "requires": { + "array-includes": "^3.0.3", + "contains-path": "^0.1.0", + "debug": "^2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.2", + "eslint-module-utils": "^2.4.0", + "has": "^1.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.0", + "read-pkg-up": "^2.0.0", + "resolve": "^1.11.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "isarray": "^1.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-plugin-node": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-10.0.0.tgz", + "integrity": "sha512-1CSyM/QCjs6PXaT18+zuAXsjXGIGo5Rw630rSKwokSs2jrYURQc4R5JZpoanNCqwNmepg+0eZ9L7YiRUJb8jiQ==", + "dev": true, + "requires": { + "eslint-plugin-es": "^2.0.0", + "eslint-utils": "^1.4.2", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "eslint-plugin-promise": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz", + "integrity": "sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==", + "dev": true + }, + "eslint-plugin-standard": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz", + "integrity": "sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==", + "dev": true + }, + "eslint-scope": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", + "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", + "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "eslint-visitor-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz", + "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==", + "dev": true + }, + "espree": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-4.0.0.tgz", + "integrity": "sha512-kapdTCt1bjmspxStVKX6huolXVV5ZfyZguY1lcfhVVZstce3bqxH9mcLzNn3/mlgW6wQ732+0fuG9v7h0ZQoKg==", + "dev": true, + "requires": { + "acorn": "^5.6.0", + "acorn-jsx": "^4.1.1" + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz", + "integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==", + "dev": true + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "external-editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "dev": true, + "requires": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "fast-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.0.tgz", + "integrity": "sha512-TrUz3THiq2Vy3bjfQUB2wNyPdGBeGmdjbzzBLhfHN4YFurYptCKwGq/TfiRavbGywFRzY6U2CdmQ1zmsY5yYaw==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.0", + "merge2": "^1.3.0", + "micromatch": "^4.0.2" + } + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fastq": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.6.0.tgz", + "integrity": "sha512-jmxqQ3Z/nXoeyDmWAzF9kH1aGZSis6e/SbfPmJpUnyZ0ogr6iscHQaml4wsEepEWSdtmpy+eVXmCRIMpxaXqOA==", + "dev": true, + "requires": { + "reusify": "^1.0.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + } + }, + "flatted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.1.tgz", + "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==", + "dev": true + }, + "follow-redirects": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.9.0.tgz", + "integrity": "sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A==", + "dev": true, + "requires": { + "debug": "^3.0.0" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", + "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globby": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", + "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + } + }, + "google-libphonenumber": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.5.tgz", + "integrity": "sha512-Y0r7MFCI11UDLn0KaMPBEInhROyIOkWkQIyvWMFVF2I+h+sHE3vbl5a7FVe39td6u/w+nlKDdUMP9dMOZyv+2Q==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", + "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==", + "dev": true + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "dev": true, + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "dev": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "hosted-git-info": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.5.tgz", + "integrity": "sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==", + "dev": true + }, + "html-encoding-sniffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", + "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.1" + } + }, + "http-proxy": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz", + "integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-server": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-0.11.1.tgz", + "integrity": "sha512-6JeGDGoujJLmhjiRGlt8yK8Z9Kl0vnl/dQoQZlc4oeqaUoAKQg94NILLfrY3oWzSyFaQCVNTcKE5PZ3cH8VP9w==", + "dev": true, + "requires": { + "colors": "1.0.3", + "corser": "~2.0.0", + "ecstatic": "^3.0.0", + "http-proxy": "^1.8.1", + "opener": "~1.4.0", + "optimist": "0.6.x", + "portfinder": "^1.0.13", + "union": "~0.4.3" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz", + "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==", + "dev": true + }, + "import-fresh": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.1.0.tgz", + "integrity": "sha512-PpuksHKGt8rXfWEr9m9EHIpgyyaltBy8+eF6GJM0QCAxMgxCfucMF3mjecK2QsJr0amJW7gTqh5/wht0z2UhEQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "inquirer": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.0.6.tgz", + "integrity": "sha1-4EqqnQW3o8ubD0B9BDdfBEcZA0c=", + "dev": true, + "requires": { + "ansi-escapes": "^1.1.0", + "chalk": "^1.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.0.1", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rx": "^4.1.0", + "string-width": "^2.0.0", + "strip-ansi": "^3.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "inversify": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/inversify/-/inversify-4.13.0.tgz", + "integrity": "sha512-O5d8y7gKtyRwrvTLZzYET3kdFjqUy58sGpBYMARF13mzqDobpfBXVOPLH7HmnD2VR6Q+1HzZtslGvsdQfeb0SA==", + "dev": true + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "ip-regex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", + "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "dev": true + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "dev": true, + "requires": { + "isobject": "^4.0.0" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "^1.0.1" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", + "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "jasmine": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-3.5.0.tgz", + "integrity": "sha512-DYypSryORqzsGoMazemIHUfMkXM7I7easFaxAvNM3Mr6Xz3Fy36TupTrAOxZWN8MVKEU5xECv22J4tUQf3uBzQ==", + "dev": true, + "requires": { + "glob": "^7.1.4", + "jasmine-core": "~3.5.0" + } + }, + "jasmine-core": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.5.0.tgz", + "integrity": "sha512-nCeAiw37MIMA9w9IXso7bRaLl+c/ef3wnxsoSAlYrzS+Ot0zTG6nU8G/cIfGkqpkjX2wNaIW9RFG0TwIFnG6bA==", + "dev": true + }, + "jasmine-ts": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/jasmine-ts/-/jasmine-ts-0.3.0.tgz", + "integrity": "sha512-K5joodjVOh3bnD06CNXC8P5htDq/r0Rhjv66ECOpdIGFLly8kM7V+X/GXcd9kv+xO+tIq3q9Y8B5OF6yr/iiDw==", + "dev": true, + "requires": { + "yargs": "^8.0.2" + } + }, + "javascript-obfuscator": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/javascript-obfuscator/-/javascript-obfuscator-0.18.1.tgz", + "integrity": "sha512-pQ2DyRV4j0neaWdII1S7iJftCyks9H7afVkQRSE4gslkqpeqyM1DE0eapsZKHR0BnYvw3tPU+Ky+j4yhzcxRZA==", + "dev": true, + "requires": { + "@babel/runtime": "7.0.0-rc.1", + "chalk": "2.4.1", + "chance": "1.0.16", + "class-validator": "0.9.1", + "commander": "2.17.1", + "escodegen-wallaby": "1.6.19", + "espree": "4.0.0", + "estraverse": "4.2.0", + "inversify": "4.13.0", + "js-string-escape": "1.0.1", + "md5": "2.2.1", + "mkdirp": "0.5.1", + "multimatch": "2.1.0", + "opencollective": "1.0.3", + "reflect-metadata": "0.1.12", + "source-map-support": "0.5.8", + "string-template": "1.0.0", + "tslib": "1.9.3" + }, + "dependencies": { + "source-map-support": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.8.tgz", + "integrity": "sha512-WqAEWPdb78u25RfKzOF0swBpY0dKrNdjc4GvLwm7ScX/o9bj8Eh/YL8mcMhBHYDGl87UkkSXDOFnW4G7GhWhGg==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + } + } + }, + "jquery": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", + "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==", + "dev": true + }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==", + "dev": true + }, + "js-string-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", + "integrity": "sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "dependencies": { + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + } + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "jsdom": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-15.2.0.tgz", + "integrity": "sha512-+hRyEfjRPFwTYMmSQ3/f7U9nP8ZNZmbkmUek760ZpxnCPWJIhaaLRuUSvpJ36fZKCGENxLwxClzwpOpnXNfChQ==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "acorn": "^7.1.0", + "acorn-globals": "^4.3.2", + "array-equal": "^1.0.0", + "cssom": "^0.4.1", + "cssstyle": "^2.0.0", + "data-urls": "^1.1.0", + "domexception": "^1.0.1", + "escodegen": "^1.11.1", + "html-encoding-sniffer": "^1.0.2", + "nwsapi": "^2.1.4", + "parse5": "5.1.0", + "pn": "^1.1.0", + "request": "^2.88.0", + "request-promise-native": "^1.0.7", + "saxes": "^3.1.9", + "symbol-tree": "^3.2.2", + "tough-cookie": "^3.0.1", + "w3c-hr-time": "^1.0.1", + "w3c-xmlserializer": "^1.1.2", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^7.0.0", + "ws": "^7.0.0", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "acorn": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", + "dev": true + }, + "escodegen": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.12.0.tgz", + "integrity": "sha512-TuA+EhsanGcme5T3R0L80u4t8CpbXQjegRmf7+FPTJrtCTErXFeelblRgHQa1FofEzqYYJmJ/OqjTwREp9qgmg==", + "dev": true, + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=", + "dev": true + } + } + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "dev": true + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, + "lodash.unescape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", + "integrity": "sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=", + "dev": true + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "make-error": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz", + "integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==", + "dev": true + }, + "md5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", + "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", + "dev": true, + "requires": { + "charenc": "~0.0.1", + "crypt": "~0.0.1", + "is-buffer": "~1.1.1" + } + }, + "mem": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz", + "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "merge2": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.3.0.tgz", + "integrity": "sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "multimatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", + "integrity": "sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=", + "dev": true, + "requires": { + "array-differ": "^1.0.0", + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "minimatch": "^3.0.0" + }, + "dependencies": { + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + } + } + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-fetch": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.6.3.tgz", + "integrity": "sha1-3CNO3WSJmC1Y6PDbT2lQKavNjAQ=", + "dev": true, + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "nwsapi": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.1.4.tgz", + "integrity": "sha512-iGfd9Y6SFdTNldEy2L0GUhcarIutFmk+MPWIn9dmj8NMIup03G08uUF2KGbbmv/Ux4RT0VZJoP/sVbWA6d/VIw==", + "dev": true + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-inspect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", + "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.values": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.0.tgz", + "integrity": "sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "opencollective": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/opencollective/-/opencollective-1.0.3.tgz", + "integrity": "sha1-ruY3K8KBRFg2kMPKja7PwSDdDvE=", + "dev": true, + "requires": { + "babel-polyfill": "6.23.0", + "chalk": "1.1.3", + "inquirer": "3.0.6", + "minimist": "1.2.0", + "node-fetch": "1.6.3", + "opn": "4.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "opener": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.4.3.tgz", + "integrity": "sha1-XG2ixdflgx6P+jlklQ+NZnSskLg=", + "dev": true + }, + "opn": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/opn/-/opn-4.0.2.tgz", + "integrity": "sha1-erwi5kTf9jsKltWrfyeQwPAavJU=", + "dev": true, + "requires": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "dev": true, + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "picomatch": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.0.7.tgz", + "integrity": "sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "pn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", + "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", + "dev": true + }, + "portfinder": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.25.tgz", + "integrity": "sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg==", + "dev": true, + "requires": { + "async": "^2.6.2", + "debug": "^3.1.1", + "mkdirp": "^0.5.1" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "psl": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.4.0.tgz", + "integrity": "sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "qs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-2.3.3.tgz", + "integrity": "sha1-6eha2+ddoLvkyOBHaghikPhjtAQ=", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "reflect-metadata": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.12.tgz", + "integrity": "sha512-n+IyV+nGz3+0q3/Yf1ra12KpCyi001bi4XFxSjbiWWjfqb52iTTtpGXmCCAOWWIAn9KEuFZKGqBERHmrtScZ3A==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", + "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==", + "dev": true + }, + "regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "dev": true, + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + } + } + } + }, + "request-promise-core": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", + "integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==", + "dev": true, + "requires": { + "lodash": "^4.17.11" + } + }, + "request-promise-native": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.7.tgz", + "integrity": "sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w==", + "dev": true, + "requires": { + "request-promise-core": "1.1.2", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + }, + "dependencies": { + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rollup": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.24.0.tgz", + "integrity": "sha512-PiFETY/rPwodQ8TTC52Nz2DSCYUATznGh/ChnxActCr8rV5FIk3afBUb3uxNritQW/Jpbdn3kq1Rwh1HHYMwdQ==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/node": "*", + "acorn": "^7.1.0" + }, + "dependencies": { + "acorn": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", + "dev": true + } + } + }, + "rollup-plugin-copy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-copy/-/rollup-plugin-copy-3.1.0.tgz", + "integrity": "sha512-oVw3ljRV5jv7Yw/6eCEHntVs9Mc+NFglc0iU0J8ei76gldYmtBQ0M/j6WAkZUFVRSrhgfCrEakUllnN87V2f4w==", + "dev": true, + "requires": { + "@types/fs-extra": "^8.0.0", + "colorette": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "10.0.1", + "is-plain-object": "^3.0.0" + } + }, + "rollup-plugin-javascript-obfuscator": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/rollup-plugin-javascript-obfuscator/-/rollup-plugin-javascript-obfuscator-1.0.4.tgz", + "integrity": "sha512-pFn5NTqbjWDNMW2WIW9x+GecouGN5Y6fd6oOPLtLwbb0VlBoAiflrbW7WqK1k19ptEIAf5IfAYv0GNIVefhw/A==", + "dev": true, + "requires": { + "javascript-obfuscator": "^0.18.1" + } + }, + "rollup-plugin-typescript": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript/-/rollup-plugin-typescript-1.0.1.tgz", + "integrity": "sha512-rwJDNn9jv/NsKZuyBb/h0jsclP4CJ58qbvZt2Q9zDIGILF2LtdtvCqMOL+Gq9IVq5MTrTlHZNrn8h7VjQgd8tw==", + "dev": true, + "requires": { + "resolve": "^1.10.0", + "rollup-pluginutils": "^2.5.0" + } + }, + "rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "run-parallel": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", + "integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==", + "dev": true + }, + "rx": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz", + "integrity": "sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=", + "dev": true + }, + "rxjs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz", + "integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "saxes": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-3.1.11.tgz", + "integrity": "sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==", + "dev": true, + "requires": { + "xmlchars": "^2.1.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", + "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", + "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true + }, + "string-template": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", + "integrity": "sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "string.prototype.trimleft": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz", + "integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "string.prototype.trimright": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz", + "integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-json-comments": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", + "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "table": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", + "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "lodash": "^4.17.14", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "tough-cookie": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", + "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", + "dev": true, + "requires": { + "ip-regex": "^2.1.0", + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "ts-node": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", + "integrity": "sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==", + "dev": true, + "requires": { + "arrify": "^1.0.0", + "buffer-from": "^1.1.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "source-map-support": "^0.5.6", + "yn": "^2.0.0" + } + }, + "tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "dev": true + }, + "tsutils": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", + "integrity": "sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "typescript": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz", + "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==", + "dev": true + }, + "union": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/union/-/union-0.4.6.tgz", + "integrity": "sha1-GY+9rrolTniLDvy2MLwR8kopWeA=", + "dev": true, + "requires": { + "qs": "~2.3.3" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "url-join": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", + "integrity": "sha1-WvIvGMBSoACkjXuCxenC4v7tpyg=", + "dev": true + }, + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", + "dev": true + }, + "v8-compile-cache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz", + "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validator": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-10.4.0.tgz", + "integrity": "sha512-Q/wBy3LB1uOyssgNlXSRmaf22NxjvDNZM2MtIQ4jaEOAB61xsh1TQxsq1CgzUMBV1lDrVMogIh8GjG1DYW0zLg==", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "w3c-hr-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", + "integrity": "sha1-gqwr/2PZUOqeMYmlimViX+3xkEU=", + "dev": true, + "requires": { + "browser-process-hrtime": "^0.1.2" + } + }, + "w3c-xmlserializer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz", + "integrity": "sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==", + "dev": true, + "requires": { + "domexception": "^1.0.1", + "webidl-conversions": "^4.0.2", + "xml-name-validator": "^3.0.0" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.24" + } + }, + "whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "ws": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.0.tgz", + "integrity": "sha512-+SqNqFbwTm/0DC18KYzIsMTnEWpLwJsiasW/O17la4iDRRIO9uaHbvKiAS3AHgTiuuWerK/brj4O6MYZkei9xg==", + "dev": true, + "requires": { + "async-limiter": "^1.0.0" + } + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, + "yargs": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-8.0.2.tgz", + "integrity": "sha1-YpmpBVsc78lp/355wdkY3Osiw2A=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "read-pkg-up": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^7.0.0" + } + }, + "yargs-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz", + "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + }, + "yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha1-5a2ryKz0CPY4X8dklWhMiOavaJo=", + "dev": true + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000000..57a3a5fee0 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,53 @@ +{ + "name": "proxy.py", + "version": "1.0.0", + "description": "Frontend dashboard for proxy.py", + "main": "index.js", + "scripts": { + "clean": "rm -rf build", + "lint": "eslint --global $ src/*.ts", + "pretest": "npm run clean && npm run lint && tsc --target es5 --outDir build test/test.ts", + "test": "jasmine build/test/test.js", + "build": "npm test && rollup -c", + "start": "pushd ../public && http-server -g true -i false -d false -c-1 --no-dotfiles . && popd", + "watch": "rollup -c -w" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/abhinavsingh/proxy.py.git" + }, + "author": "Abhinav Singh", + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/abhinavsingh/proxy.py/issues" + }, + "homepage": "https://github.com/abhinavsingh/proxy.py#readme", + "devDependencies": { + "@types/jasmine": "^3.4.4", + "@types/jquery": "^3.3.31", + "@types/js-cookie": "^2.2.4", + "@typescript-eslint/eslint-plugin": "^2.5.0", + "@typescript-eslint/parser": "^2.5.0", + "chrome-devtools-frontend": "^1.0.706688", + "eslint": "^6.5.1", + "eslint-config-standard": "^14.1.0", + "eslint-plugin-import": "^2.18.2", + "eslint-plugin-node": "^10.0.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-standard": "^4.0.1", + "http-server": "^0.11.1", + "jasmine": "^3.5.0", + "jasmine-ts": "^0.3.0", + "jquery": "^3.4.1", + "js-cookie": "^2.2.1", + "jsdom": "^15.2.0", + "ncp": "^2.0.0", + "rollup": "^1.24.0", + "rollup-plugin-copy": "^3.1.0", + "rollup-plugin-javascript-obfuscator": "^1.0.4", + "rollup-plugin-typescript": "^1.0.1", + "ts-node": "^7.0.1", + "typescript": "^3.6.4", + "ws": "^7.2.0" + } +} diff --git a/dashboard/rollup.config.js b/dashboard/rollup.config.js new file mode 100644 index 0000000000..26ea21d82b --- /dev/null +++ b/dashboard/rollup.config.js @@ -0,0 +1,39 @@ +const typescript = require('rollup-plugin-typescript'); +const copy = require('rollup-plugin-copy'); +const obfuscatorPlugin = require('rollup-plugin-javascript-obfuscator'); + +module.exports = { + input: 'src/proxy.ts', + output: { + file: '../public/dashboard/proxy.js', + format: 'umd', + name: 'projectbundle', + sourcemap: true + }, + plugins: [ + typescript(), + copy({ + targets: [{ + src: 'static/**/*', + dest: '../public/dashboard', + }, { + src: 'src/proxy.html', + dest: '../public/dashboard', + }, { + src: 'src/proxy.css', + dest: '../public/dashboard', + }], + }), + obfuscatorPlugin({ + log: false, + sourceMap: true, + compact: true, + stringArray: true, + rotateStringArray: true, + transformObjectKeys: true, + stringArrayThreshold: 1, + stringArrayEncoding: 'rc4', + identifierNamesGenerator: 'mangled', + }) + ] +}; diff --git a/dashboard/spec/helpers/browser.js b/dashboard/spec/helpers/browser.js new file mode 100644 index 0000000000..1896bcecc7 --- /dev/null +++ b/dashboard/spec/helpers/browser.js @@ -0,0 +1,9 @@ +let jsdom = require('jsdom'); +let WebSocket = require('ws') + +const window = new jsdom.JSDOM('').window; + +global.jQuery = global.$ = require('jquery')(window); +global.window = window; +global.document = window.document; +global.WebSocket = WebSocket; diff --git a/dashboard/spec/support/jasmine.json b/dashboard/spec/support/jasmine.json new file mode 100644 index 0000000000..370fc44641 --- /dev/null +++ b/dashboard/spec/support/jasmine.json @@ -0,0 +1,11 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*[sS]pec.js" + ], + "helpers": [ + "helpers/**/*.js" + ], + "stopSpecOnExpectationFailure": false, + "random": true +} diff --git a/dashboard/src/devtools.ts b/dashboard/src/devtools.ts new file mode 100644 index 0000000000..91c1f2527e --- /dev/null +++ b/dashboard/src/devtools.ts @@ -0,0 +1,37 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +const path = require('path') +const fs = require('fs') +const ncp = require('ncp').ncp +ncp.limit = 16 + +const publicFolderPath = path.join(__dirname, 'public') +const destinationFolderPath = path.join(publicFolderPath, 'devtools') + +const publicFolderExists = fs.existsSync(publicFolderPath) +if (!publicFolderExists) { + console.error(publicFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.') + process.exit(1) +} + +const destinationFolderExists = fs.existsSync(destinationFolderPath) +if (!destinationFolderExists) { + console.error(destinationFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.') + process.exit(1) +} + +const chromeDevTools = path.dirname(require.resolve('chrome-devtools-frontend/front_end/inspector.html')) + +console.log(chromeDevTools + ' ---> ' + destinationFolderPath) +ncp(chromeDevTools, destinationFolderPath, (err: any) => { + if (err) { + return console.error(err) + } + console.log('Copy successful!!!') +}) diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css new file mode 100644 index 0000000000..de703af6bc --- /dev/null +++ b/dashboard/src/proxy.css @@ -0,0 +1,35 @@ +#app { + background-color: #eeeeee; + height: 100%; +} +#app .remove-api-spec { + top: 10px; + right: 15px; +} + +.api-path-spec .list-group-item { + padding-left: 50px; +} + +#app-header { + padding-top: 10px; + padding-bottom: 5px; +} + +#app-body .list-group { + margin-left: 15px; + margin-right: 15px; + margin-bottom: 10px; +} + +.sunny-morning-white-gradient{ + background-image: linear-gradient(120deg,#eeeeee 0,#ffecd2 100%); +} + +.mean-fruit-white-gradient{ + background-image:linear-gradient(120deg,#eeeeee 0,#ffefbf 100%); +} + +.proxy-data { + display: none; +} diff --git a/dashboard/src/proxy.html b/dashboard/src/proxy.html new file mode 100644 index 0000000000..34ab8dd5cc --- /dev/null +++ b/dashboard/src/proxy.html @@ -0,0 +1,147 @@ + + + + + + + Proxy.py Dashboard + + +
+ + + PROXY.PY + + +
+
    +
  • + + + Home + +
  • +
  • + + + API Development + +
  • +
  • + + + Inspect Traffic + +
  • +
  • + + + Short Links + +
  • +
  • + + + Traffic Controls + +
  • +
  • + + + Settings + +
  • +
+
+
+ +
+
+
+
+
+
+
+

API Development

+
+
+ +
+
+
+
+
+
+ + api.example.com 3 Resources + + +
+
+ /v1/users/ +
+
+ /v1/groups/ +
+
+ /v1/messages/ +
+
+
+
+ + my.api 1 Resource + + +
+
+ /api/ +
+
+
+
+
+
+
+
+
+
+ +
+
+ + + Server Status + +
+
+ + + Abhinav Singh and contributors + +
+
+ + + + + + + + + diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts new file mode 100644 index 0000000000..7c89aaaaa5 --- /dev/null +++ b/dashboard/src/proxy.ts @@ -0,0 +1,217 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ + +class ApiDevelopment { + private specs: Map>; + + constructor () { + this.specs = new Map() + this.fetchExistingSpecs() + } + + private fetchExistingSpecs () { + // TODO: Fetch list of currently configured APIs from the backend + const apiExampleOrgSpec = new Map() + apiExampleOrgSpec.set('/v1/users/', { + count: 2, + next: null, + previous: null, + results: [ + { + email: 'you@example.com', + groups: [], + url: 'api.example.org/v1/users/1/', + username: 'admin' + }, + { + email: 'someone@example.com', + groups: [], + url: 'api.example.org/v1/users/2/', + username: 'someone' + } + ] + }) + this.specs.set('api.example.org', apiExampleOrgSpec) + } +} + +class WebsocketApi { + private hostname: string = 'localhost'; + private port: number = 8899; + private wsPrefix: string = '/dashboard'; + private wsScheme: string = 'ws'; + private ws: WebSocket; + private wsPath: string = this.wsScheme + '://' + this.hostname + ':' + this.port + this.wsPrefix; + + private mid: number = 0; + private lastPingId: number; + private lastPingTime: number; + + private readonly schedulePingEveryMs: number = 1000; + private readonly scheduleReconnectEveryMs: number = 5000; + + private serverPingTimer: number; + private serverConnectTimer: number; + + private inspectionEnabled: boolean; + + constructor () { + this.scheduleServerConnect(0) + } + + public enableInspection () { + // TODO: Set flag to true only once response has been received from the server + this.inspectionEnabled = true + this.ws.send(JSON.stringify({ id: this.mid, method: 'enable_inspection' })) + this.mid++ + } + + public disableInspection () { + this.inspectionEnabled = false + this.ws.send(JSON.stringify({ id: this.mid, method: 'disable_inspection' })) + this.mid++ + } + + private scheduleServerConnect (after_ms: number = this.scheduleReconnectEveryMs) { + this.clearServerConnectTimer() + this.serverConnectTimer = window.setTimeout( + this.connectToServer.bind(this), after_ms) + } + + private connectToServer () { + this.ws = new WebSocket(this.wsPath) + this.ws.onopen = this.onServerWSOpen.bind(this) + this.ws.onmessage = this.onServerWSMessage.bind(this) + this.ws.onerror = this.onServerWSError.bind(this) + this.ws.onclose = this.onServerWSClose.bind(this) + } + + private clearServerConnectTimer () { + if (this.serverConnectTimer == null) { + return + } + window.clearTimeout(this.serverConnectTimer) + this.serverConnectTimer = null + } + + private scheduleServerPing (after_ms: number = this.schedulePingEveryMs) { + this.clearServerPingTimer() + this.serverPingTimer = window.setTimeout( + this.pingServer.bind(this), after_ms) + } + + private pingServer () { + this.lastPingId = this.mid + this.lastPingTime = ProxyDashboard.getTime() + this.mid++ + // console.log('Pinging server with id:%d', this.last_ping_id); + this.ws.send(JSON.stringify({ id: this.lastPingId, method: 'ping' })) + } + + private clearServerPingTimer () { + if (this.serverPingTimer != null) { + window.clearTimeout(this.serverPingTimer) + this.serverPingTimer = null + } + this.lastPingTime = null + this.lastPingId = null + } + + private onServerWSOpen (ev: MessageEvent) { + this.clearServerConnectTimer() + ProxyDashboard.setServerStatusSuccess('Connected...') + this.scheduleServerPing(0) + } + + private onServerWSMessage (ev: MessageEvent) { + const message = JSON.parse(ev.data) + if (message.id === this.lastPingId) { + ProxyDashboard.setServerStatusSuccess( + String((ProxyDashboard.getTime() - this.lastPingTime) + ' ms')) + this.clearServerPingTimer() + this.scheduleServerPing() + } else { + console.log(message) + } + } + + private onServerWSError (ev: MessageEvent) { + ProxyDashboard.setServerStatusDanger() + } + + private onServerWSClose (ev: MessageEvent) { + this.clearServerPingTimer() + this.scheduleServerConnect() + ProxyDashboard.setServerStatusDanger() + } +} + +export class ProxyDashboard { + private websocketApi: WebsocketApi + private apiDevelopment: ApiDevelopment + + constructor () { + this.websocketApi = new WebsocketApi() + const that = this + $('#proxyTopNav>ul>li>a').on('click', function () { + that.switchTab(this) + }) + this.apiDevelopment = new ApiDevelopment() + } + + public static getTime () { + const date = new Date() + return date.getTime() + } + + public static setServerStatusDanger () { + $('#proxyServerStatus').parent('div') + .removeClass('text-success') + .addClass('text-danger') + $('#proxyServerStatusSummary').text('') + } + + public static setServerStatusSuccess (summary: string) { + $('#proxyServerStatus').parent('div') + .removeClass('text-danger') + .addClass('text-success') + $('#proxyServerStatusSummary').text( + '(' + summary + ')') + } + + private switchTab (element: HTMLElement) { + const activeLi = $('#proxyTopNav>ul>li.active') + const activeTabId = activeLi.children('a').attr('id') + const clickedTabId = $(element).attr('id') + const clickedTabContentId = $(element).text().trim().toLowerCase().replace(' ', '-') + + activeLi.removeClass('active') + $(element.parentNode).addClass('active') + console.log('Clicked id %s, showing %s', clickedTabId, clickedTabContentId) + + $('#app>div.proxy-data').hide() + $('#' + clickedTabContentId).show() + + // TODO: Tab ids shouldn't be hardcoded. + // Templatize proxy.html and refer to tab_id via enum or constants + // + // 1. Enable inspection if user moved to inspect tab + // 2. Disable inspection if user moved away from inspect tab + // 3. Do nothing if activeTabId == clickedTabId + if (clickedTabId !== activeTabId) { + if (clickedTabId === 'proxyInspect') { + this.websocketApi.enableInspection() + } else if (activeTabId === 'proxyInspect') { + this.websocketApi.disableInspection() + } + } + } +} + +(window as any).ProxyDashboard = ProxyDashboard diff --git a/dashboard/static/bootstrap-4.3.1.min.css b/dashboard/static/bootstrap-4.3.1.min.css new file mode 100644 index 0000000000..92e3fe8712 --- /dev/null +++ b/dashboard/static/bootstrap-4.3.1.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.3.1 (https://getbootstrap.com/) + * Copyright 2011-2019 The Bootstrap Authors + * Copyright 2011-2019 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-break:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{color:#212529;background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#7abaff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#b3b7bb}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#8fd19e}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#86cfda}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed969e}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#fbfcfc}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#95999c}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#343a40;border-color:#454d55}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#343a40}.table-dark td,.table-dark th,.table-dark thead th{border-color:#454d55}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding-top:.375rem;padding-bottom:.375rem;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(40,167,69,.9);border-radius:.25rem}.form-control.is-valid,.was-validated .form-control:valid{border-color:#28a745;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.form-control.is-valid~.valid-feedback,.form-control.is-valid~.valid-tooltip,.was-validated .form-control:valid~.valid-feedback,.was-validated .form-control:valid~.valid-tooltip{display:block}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#28a745;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-select.is-valid~.valid-feedback,.custom-select.is-valid~.valid-tooltip,.was-validated .custom-select:valid~.valid-feedback,.was-validated .custom-select:valid~.valid-tooltip{display:block}.form-control-file.is-valid~.valid-feedback,.form-control-file.is-valid~.valid-tooltip,.was-validated .form-control-file:valid~.valid-feedback,.was-validated .form-control-file:valid~.valid-tooltip{display:block}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#28a745}.custom-control-input.is-valid~.valid-feedback,.custom-control-input.is-valid~.valid-tooltip,.was-validated .custom-control-input:valid~.valid-feedback,.was-validated .custom-control-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#34ce57;background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid~.valid-feedback,.custom-file-input.is-valid~.valid-tooltip,.was-validated .custom-file-input:valid~.valid-feedback,.was-validated .custom-file-input:valid~.valid-tooltip{display:block}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E");background-repeat:no-repeat;background-position:center right calc(.375em + .1875rem);background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-control.is-invalid~.invalid-feedback,.form-control.is-invalid~.invalid-tooltip,.was-validated .form-control:invalid~.invalid-feedback,.was-validated .form-control:invalid~.invalid-tooltip{display:block}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#dc3545;padding-right:calc((1em + .75rem) * 3 / 4 + 1.75rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc3545' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23dc3545' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-select.is-invalid~.invalid-feedback,.custom-select.is-invalid~.invalid-tooltip,.was-validated .custom-select:invalid~.invalid-feedback,.was-validated .custom-select:invalid~.invalid-tooltip{display:block}.form-control-file.is-invalid~.invalid-feedback,.form-control-file.is-invalid~.invalid-tooltip,.was-validated .form-control-file:invalid~.invalid-feedback,.was-validated .form-control-file:invalid~.invalid-tooltip{display:block}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#dc3545}.custom-control-input.is-invalid~.invalid-feedback,.custom-control-input.is-invalid~.invalid-tooltip,.was-validated .custom-control-input:invalid~.invalid-feedback,.was-validated .custom-control-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#e4606d;background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid~.invalid-feedback,.custom-file-input.is-invalid~.invalid-tooltip,.was-validated .custom-file-input:invalid~.invalid-feedback,.was-validated .custom-file-input:invalid~.invalid-tooltip{display:block}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-outline-primary{color:#007bff;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline;box-shadow:none}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;z-index:-1;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;vertical-align:middle;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:disabled~.custom-file-label{background-color:#e9ecef}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:calc(1rem + .4rem);padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{transition:none}}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#007bff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{transition:none}}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar>.container,.navbar>.container-fluid{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group:first-child .list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card>.list-group:last-child .list-group-item:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img{width:100%;border-radius:calc(.25rem - 1px)}.card-img-top{width:100%;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img-bottom{width:100%;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{display:-ms-flexbox;display:flex;-ms-flex:1 0 0%;flex:1 0 0%;-ms-flex-direction:column;flex-direction:column;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:first-of-type) .card-header:first-child{border-radius:0}.accordion>.card:not(:first-of-type):not(:last-of-type){border-bottom:0;border-radius:0}.accordion>.card:first-of-type{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:last-of-type{border-top-left-radius:0;border-top-right-radius:0}.accordion>.card .card-header{margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:1;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#0062cc}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.badge-secondary{color:#fff;background-color:#6c757d}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#545b62}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.badge-success{color:#fff;background-color:#28a745}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#1e7e34}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.badge-info{color:#fff;background-color:#17a2b8}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#117a8b}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.badge-warning{color:#212529;background-color:#ffc107}a.badge-warning:focus,a.badge-warning:hover{color:#212529;background-color:#d39e00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.badge-danger{color:#fff;background-color:#dc3545}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#bd2130}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.badge-light{color:#212529;background-color:#f8f9fa}a.badge-light:focus,a.badge-light:hover{color:#212529;background-color:#dae0e5}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.badge-dark{color:#fff;background-color:#343a40}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#1d2124}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-sm .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-md .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-lg .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl .list-group-item{margin-right:-1px;margin-bottom:0}.list-group-horizontal-xl .list-group-item:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl .list-group-item:last-child{margin-right:0;border-top-right-radius:.25rem;border-bottom-right-radius:.25rem;border-bottom-left-radius:0}}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-flush .list-group-item:last-child{margin-bottom:-1px}.list-group-flush:first-child .list-group-item:first-child{border-top:0}.list-group-flush:last-child .list-group-item:last-child{margin-bottom:0;border-bottom:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}a.close.disabled{pointer-events:none}.toast{max-width:350px;overflow:hidden;font-size:.875rem;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .25rem .75rem rgba(0,0,0,.1);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:.3rem;border-top-right-radius:.3rem}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:1rem;border-top:1px solid #dee2e6;border-bottom-right-radius:.3rem;border-bottom-left-radius:.3rem}.modal-footer>:not(:first-child){margin-left:.25rem}.modal-footer>:not(:last-child){margin-right:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc((.5rem + 1px) * -1)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc((.5rem + 1px) * -1);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:0s .6s opacity}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0056b3!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#494f54!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#19692c!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#0f6674!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#ba8b00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#a71d2a!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#cbd3da!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#121416!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;overflow-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/dashboard/static/bootstrap-4.3.1.min.js b/dashboard/static/bootstrap-4.3.1.min.js new file mode 100644 index 0000000000..c4c0d1f95c --- /dev/null +++ b/dashboard/static/bootstrap-4.3.1.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.3.1 (https://getbootstrap.com/) + * Copyright 2011-2019 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e((t=t||self).bootstrap={},t.jQuery,t.Popper)}(this,function(t,g,u){"use strict";function i(t,e){for(var n=0;nthis._items.length-1||t<0))if(this._isSliding)g(this._element).one(Q.SLID,function(){return e.to(t)});else{if(n===t)return this.pause(),void this.cycle();var i=ndocument.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},t._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},t._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=t.left+t.right
',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent",sanitize:!0,sanitizeFn:null,whiteList:Ee},je="show",He="out",Re={HIDE:"hide"+De,HIDDEN:"hidden"+De,SHOW:"show"+De,SHOWN:"shown"+De,INSERTED:"inserted"+De,CLICK:"click"+De,FOCUSIN:"focusin"+De,FOCUSOUT:"focusout"+De,MOUSEENTER:"mouseenter"+De,MOUSELEAVE:"mouseleave"+De},xe="fade",Fe="show",Ue=".tooltip-inner",We=".arrow",qe="hover",Me="focus",Ke="click",Qe="manual",Be=function(){function i(t,e){if("undefined"==typeof u)throw new TypeError("Bootstrap's tooltips require Popper.js (https://popper.js.org/)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var t=i.prototype;return t.enable=function(){this._isEnabled=!0},t.disable=function(){this._isEnabled=!1},t.toggleEnabled=function(){this._isEnabled=!this._isEnabled},t.toggle=function(t){if(this._isEnabled)if(t){var e=this.constructor.DATA_KEY,n=g(t.currentTarget).data(e);n||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),g(t.currentTarget).data(e,n)),n._activeTrigger.click=!n._activeTrigger.click,n._isWithActiveTrigger()?n._enter(null,n):n._leave(null,n)}else{if(g(this.getTipElement()).hasClass(Fe))return void this._leave(null,this);this._enter(null,this)}},t.dispose=function(){clearTimeout(this._timeout),g.removeData(this.element,this.constructor.DATA_KEY),g(this.element).off(this.constructor.EVENT_KEY),g(this.element).closest(".modal").off("hide.bs.modal"),this.tip&&g(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,(this._activeTrigger=null)!==this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},t.show=function(){var e=this;if("none"===g(this.element).css("display"))throw new Error("Please use show on visible elements");var t=g.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){g(this.element).trigger(t);var n=_.findShadowRoot(this.element),i=g.contains(null!==n?n:this.element.ownerDocument.documentElement,this.element);if(t.isDefaultPrevented()||!i)return;var o=this.getTipElement(),r=_.getUID(this.constructor.NAME);o.setAttribute("id",r),this.element.setAttribute("aria-describedby",r),this.setContent(),this.config.animation&&g(o).addClass(xe);var s="function"==typeof this.config.placement?this.config.placement.call(this,o,this.element):this.config.placement,a=this._getAttachment(s);this.addAttachmentClass(a);var l=this._getContainer();g(o).data(this.constructor.DATA_KEY,this),g.contains(this.element.ownerDocument.documentElement,this.tip)||g(o).appendTo(l),g(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new u(this.element,o,{placement:a,modifiers:{offset:this._getOffset(),flip:{behavior:this.config.fallbackPlacement},arrow:{element:We},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){return e._handlePopperPlacementChange(t)}}),g(o).addClass(Fe),"ontouchstart"in document.documentElement&&g(document.body).children().on("mouseover",null,g.noop);var c=function(){e.config.animation&&e._fixTransition();var t=e._hoverState;e._hoverState=null,g(e.element).trigger(e.constructor.Event.SHOWN),t===He&&e._leave(null,e)};if(g(this.tip).hasClass(xe)){var h=_.getTransitionDurationFromElement(this.tip);g(this.tip).one(_.TRANSITION_END,c).emulateTransitionEnd(h)}else c()}},t.hide=function(t){var e=this,n=this.getTipElement(),i=g.Event(this.constructor.Event.HIDE),o=function(){e._hoverState!==je&&n.parentNode&&n.parentNode.removeChild(n),e._cleanTipClass(),e.element.removeAttribute("aria-describedby"),g(e.element).trigger(e.constructor.Event.HIDDEN),null!==e._popper&&e._popper.destroy(),t&&t()};if(g(this.element).trigger(i),!i.isDefaultPrevented()){if(g(n).removeClass(Fe),"ontouchstart"in document.documentElement&&g(document.body).children().off("mouseover",null,g.noop),this._activeTrigger[Ke]=!1,this._activeTrigger[Me]=!1,this._activeTrigger[qe]=!1,g(this.tip).hasClass(xe)){var r=_.getTransitionDurationFromElement(n);g(n).one(_.TRANSITION_END,o).emulateTransitionEnd(r)}else o();this._hoverState=""}},t.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},t.isWithContent=function(){return Boolean(this.getTitle())},t.addAttachmentClass=function(t){g(this.getTipElement()).addClass(Ae+"-"+t)},t.getTipElement=function(){return this.tip=this.tip||g(this.config.template)[0],this.tip},t.setContent=function(){var t=this.getTipElement();this.setElementContent(g(t.querySelectorAll(Ue)),this.getTitle()),g(t).removeClass(xe+" "+Fe)},t.setElementContent=function(t,e){"object"!=typeof e||!e.nodeType&&!e.jquery?this.config.html?(this.config.sanitize&&(e=Se(e,this.config.whiteList,this.config.sanitizeFn)),t.html(e)):t.text(e):this.config.html?g(e).parent().is(t)||t.empty().append(e):t.text(g(e).text())},t.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},t._getOffset=function(){var e=this,t={};return"function"==typeof this.config.offset?t.fn=function(t){return t.offsets=l({},t.offsets,e.config.offset(t.offsets,e.element)||{}),t}:t.offset=this.config.offset,t},t._getContainer=function(){return!1===this.config.container?document.body:_.isElement(this.config.container)?g(this.config.container):g(document).find(this.config.container)},t._getAttachment=function(t){return Pe[t.toUpperCase()]},t._setListeners=function(){var i=this;this.config.trigger.split(" ").forEach(function(t){if("click"===t)g(i.element).on(i.constructor.Event.CLICK,i.config.selector,function(t){return i.toggle(t)});else if(t!==Qe){var e=t===qe?i.constructor.Event.MOUSEENTER:i.constructor.Event.FOCUSIN,n=t===qe?i.constructor.Event.MOUSELEAVE:i.constructor.Event.FOCUSOUT;g(i.element).on(e,i.config.selector,function(t){return i._enter(t)}).on(n,i.config.selector,function(t){return i._leave(t)})}}),g(this.element).closest(".modal").on("hide.bs.modal",function(){i.element&&i.hide()}),this.config.selector?this.config=l({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},t._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},t._enter=function(t,e){var n=this.constructor.DATA_KEY;(e=e||g(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),g(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusin"===t.type?Me:qe]=!0),g(e.getTipElement()).hasClass(Fe)||e._hoverState===je?e._hoverState=je:(clearTimeout(e._timeout),e._hoverState=je,e.config.delay&&e.config.delay.show?e._timeout=setTimeout(function(){e._hoverState===je&&e.show()},e.config.delay.show):e.show())},t._leave=function(t,e){var n=this.constructor.DATA_KEY;(e=e||g(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),g(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusout"===t.type?Me:qe]=!1),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=He,e.config.delay&&e.config.delay.hide?e._timeout=setTimeout(function(){e._hoverState===He&&e.hide()},e.config.delay.hide):e.hide())},t._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},t._getConfig=function(t){var e=g(this.element).data();return Object.keys(e).forEach(function(t){-1!==Oe.indexOf(t)&&delete e[t]}),"number"==typeof(t=l({},this.constructor.Default,e,"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),_.typeCheckConfig(be,t,this.constructor.DefaultType),t.sanitize&&(t.template=Se(t.template,t.whiteList,t.sanitizeFn)),t},t._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},t._cleanTipClass=function(){var t=g(this.getTipElement()),e=t.attr("class").match(Ne);null!==e&&e.length&&t.removeClass(e.join(""))},t._handlePopperPlacementChange=function(t){var e=t.instance;this.tip=e.popper,this._cleanTipClass(),this.addAttachmentClass(this._getAttachment(t.placement))},t._fixTransition=function(){var t=this.getTipElement(),e=this.config.animation;null===t.getAttribute("x-placement")&&(g(t).removeClass(xe),this.config.animation=!1,this.hide(),this.show(),this.config.animation=e)},i._jQueryInterface=function(n){return this.each(function(){var t=g(this).data(Ie),e="object"==typeof n&&n;if((t||!/dispose|hide/.test(n))&&(t||(t=new i(this,e),g(this).data(Ie,t)),"string"==typeof n)){if("undefined"==typeof t[n])throw new TypeError('No method named "'+n+'"');t[n]()}})},s(i,null,[{key:"VERSION",get:function(){return"4.3.1"}},{key:"Default",get:function(){return Le}},{key:"NAME",get:function(){return be}},{key:"DATA_KEY",get:function(){return Ie}},{key:"Event",get:function(){return Re}},{key:"EVENT_KEY",get:function(){return De}},{key:"DefaultType",get:function(){return ke}}]),i}();g.fn[be]=Be._jQueryInterface,g.fn[be].Constructor=Be,g.fn[be].noConflict=function(){return g.fn[be]=we,Be._jQueryInterface};var Ve="popover",Ye="bs.popover",ze="."+Ye,Xe=g.fn[Ve],$e="bs-popover",Ge=new RegExp("(^|\\s)"+$e+"\\S+","g"),Je=l({},Be.Default,{placement:"right",trigger:"click",content:"",template:'

'}),Ze=l({},Be.DefaultType,{content:"(string|element|function)"}),tn="fade",en="show",nn=".popover-header",on=".popover-body",rn={HIDE:"hide"+ze,HIDDEN:"hidden"+ze,SHOW:"show"+ze,SHOWN:"shown"+ze,INSERTED:"inserted"+ze,CLICK:"click"+ze,FOCUSIN:"focusin"+ze,FOCUSOUT:"focusout"+ze,MOUSEENTER:"mouseenter"+ze,MOUSELEAVE:"mouseleave"+ze},sn=function(t){var e,n;function i(){return t.apply(this,arguments)||this}n=t,(e=i).prototype=Object.create(n.prototype),(e.prototype.constructor=e).__proto__=n;var o=i.prototype;return o.isWithContent=function(){return this.getTitle()||this._getContent()},o.addAttachmentClass=function(t){g(this.getTipElement()).addClass($e+"-"+t)},o.getTipElement=function(){return this.tip=this.tip||g(this.config.template)[0],this.tip},o.setContent=function(){var t=g(this.getTipElement());this.setElementContent(t.find(nn),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(t.find(on),e),t.removeClass(tn+" "+en)},o._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},o._cleanTipClass=function(){var t=g(this.getTipElement()),e=t.attr("class").match(Ge);null!==e&&0=this._offsets[o]&&("undefined"==typeof this._offsets[o+1]||tli{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} diff --git a/dashboard/static/fonts/fontawesome-webfont.ttf b/dashboard/static/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000000000000000000000000000000000000..35acda2fa1196aad98c2adf4378a7611dd713aa3 GIT binary patch literal 165548 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 z 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 d0m81_!itTT%&fM`8Do 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 literal 0 HcmV?d00001 diff --git a/dashboard/static/jquery-3.3.1.slim.min.js b/dashboard/static/jquery-3.3.1.slim.min.js new file mode 100644 index 0000000000..f4ca9b24ba --- /dev/null +++ b/dashboard/static/jquery-3.3.1.slim.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.3.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(e,t){"use strict";var n=[],r=e.document,i=Object.getPrototypeOf,o=n.slice,a=n.concat,u=n.push,s=n.indexOf,l={},c=l.toString,f=l.hasOwnProperty,d=f.toString,p=d.call(Object),h={},g=function e(t){return"function"==typeof t&&"number"!=typeof t.nodeType},v=function e(t){return null!=t&&t===t.window},y={type:!0,src:!0,noModule:!0};function m(e,t,n){var i,o=(t=t||r).createElement("script");if(o.text=e,n)for(i in y)n[i]&&(o[i]=n[i]);t.head.appendChild(o).parentNode.removeChild(o)}function b(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[c.call(e)]||"object":typeof e}var x="3.3.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector",w=function(e,t){return new w.fn.init(e,t)},C=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;w.fn=w.prototype={jquery:x,constructor:w,length:0,toArray:function(){return o.call(this)},get:function(e){return null==e?o.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=w.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return w.each(this,e)},map:function(e){return this.pushStack(w.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(o.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n0&&t-1 in e)}var E=function(e){var t,n,r,i,o,a,u,s,l,c,f,d,p,h,g,v,y,m,b,x="sizzle"+1*new Date,w=e.document,C=0,T=0,E=ae(),N=ae(),k=ae(),A=function(e,t){return e===t&&(f=!0),0},D={}.hasOwnProperty,S=[],L=S.pop,j=S.push,q=S.push,O=S.slice,P=function(e,t){for(var n=0,r=e.length;n+~]|"+I+")"+I+"*"),_=new RegExp("="+I+"*([^\\]'\"]*?)"+I+"*\\]","g"),U=new RegExp(M),V=new RegExp("^"+R+"$"),X={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+B),PSEUDO:new RegExp("^"+M),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+I+"*(even|odd|(([+-]|)(\\d*)n|)"+I+"*(?:([+-]|)"+I+"*(\\d+)|))"+I+"*\\)|)","i"),bool:new RegExp("^(?:"+H+")$","i"),needsContext:new RegExp("^"+I+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+I+"*((?:-\\d)?\\d*)"+I+"*\\)|)(?=[^-]|$)","i")},Q=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,G=/^[^{]+\{\s*\[native \w/,K=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,J=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+I+"?|("+I+")|.)","ig"),ee=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},te=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ne=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},re=function(){d()},ie=me(function(e){return!0===e.disabled&&("form"in e||"label"in e)},{dir:"parentNode",next:"legend"});try{q.apply(S=O.call(w.childNodes),w.childNodes),S[w.childNodes.length].nodeType}catch(e){q={apply:S.length?function(e,t){j.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function oe(e,t,r,i){var o,u,l,c,f,h,y,m=t&&t.ownerDocument,C=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==C&&9!==C&&11!==C)return r;if(!i&&((t?t.ownerDocument||t:w)!==p&&d(t),t=t||p,g)){if(11!==C&&(f=K.exec(e)))if(o=f[1]){if(9===C){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(m&&(l=m.getElementById(o))&&b(t,l)&&l.id===o)return r.push(l),r}else{if(f[2])return q.apply(r,t.getElementsByTagName(e)),r;if((o=f[3])&&n.getElementsByClassName&&t.getElementsByClassName)return q.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!k[e+" "]&&(!v||!v.test(e))){if(1!==C)m=t,y=e;else if("object"!==t.nodeName.toLowerCase()){(c=t.getAttribute("id"))?c=c.replace(te,ne):t.setAttribute("id",c=x),u=(h=a(e)).length;while(u--)h[u]="#"+c+" "+ye(h[u]);y=h.join(","),m=J.test(e)&&ge(t.parentNode)||t}if(y)try{return q.apply(r,m.querySelectorAll(y)),r}catch(e){}finally{c===x&&t.removeAttribute("id")}}}return s(e.replace($,"$1"),t,r,i)}function ae(){var e=[];function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}return t}function ue(e){return e[x]=!0,e}function se(e){var t=p.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function le(e,t){var n=e.split("|"),i=n.length;while(i--)r.attrHandle[n[i]]=t}function ce(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function fe(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function de(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function pe(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&ie(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return ue(function(t){return t=+t,ue(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function ge(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}n=oe.support={},o=oe.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},d=oe.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==p&&9===a.nodeType&&a.documentElement?(p=a,h=p.documentElement,g=!o(p),w!==p&&(i=p.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=se(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=se(function(e){return e.appendChild(p.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=G.test(p.getElementsByClassName),n.getById=se(function(e){return h.appendChild(e).id=x,!p.getElementsByName||!p.getElementsByName(x).length}),n.getById?(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){return e.getAttribute("id")===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}}):(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){var n="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),r.find.TAG=n.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):n.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&g)return t.getElementsByClassName(e)},y=[],v=[],(n.qsa=G.test(p.querySelectorAll))&&(se(function(e){h.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+I+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+I+"*(?:value|"+H+")"),e.querySelectorAll("[id~="+x+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+x+"+*").length||v.push(".#.+[+~]")}),se(function(e){e.innerHTML="";var t=p.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+I+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),h.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(n.matchesSelector=G.test(m=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&se(function(e){n.disconnectedMatch=m.call(e,"*"),m.call(e,"[s!='']:x"),y.push("!=",M)}),v=v.length&&new RegExp(v.join("|")),y=y.length&&new RegExp(y.join("|")),t=G.test(h.compareDocumentPosition),b=t||G.test(h.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},A=t?function(e,t){if(e===t)return f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===p||e.ownerDocument===w&&b(w,e)?-1:t===p||t.ownerDocument===w&&b(w,t)?1:c?P(c,e)-P(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],u=[t];if(!i||!o)return e===p?-1:t===p?1:i?-1:o?1:c?P(c,e)-P(c,t):0;if(i===o)return ce(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)u.unshift(n);while(a[r]===u[r])r++;return r?ce(a[r],u[r]):a[r]===w?-1:u[r]===w?1:0},p):p},oe.matches=function(e,t){return oe(e,null,null,t)},oe.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&d(e),t=t.replace(_,"='$1']"),n.matchesSelector&&g&&!k[t+" "]&&(!y||!y.test(t))&&(!v||!v.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return oe(t,p,null,[e]).length>0},oe.contains=function(e,t){return(e.ownerDocument||e)!==p&&d(e),b(e,t)},oe.attr=function(e,t){(e.ownerDocument||e)!==p&&d(e);var i=r.attrHandle[t.toLowerCase()],o=i&&D.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},oe.escape=function(e){return(e+"").replace(te,ne)},oe.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},oe.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(A),f){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return c=null,e},i=oe.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else while(t=e[r++])n+=i(t);return n},(r=oe.selectors={cacheLength:50,createPseudo:ue,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Z,ee),e[3]=(e[3]||e[4]||e[5]||"").replace(Z,ee),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||oe.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&oe.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return X.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&U.test(n)&&(t=a(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Z,ee).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+I+")"+e+"("+I+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=oe.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace(W," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),u="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,s){var l,c,f,d,p,h,g=o!==a?"nextSibling":"previousSibling",v=t.parentNode,y=u&&t.nodeName.toLowerCase(),m=!s&&!u,b=!1;if(v){if(o){while(g){d=t;while(d=d[g])if(u?d.nodeName.toLowerCase()===y:1===d.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?v.firstChild:v.lastChild],a&&m){b=(p=(l=(c=(f=(d=v)[x]||(d[x]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]||[])[0]===C&&l[1])&&l[2],d=p&&v.childNodes[p];while(d=++p&&d&&d[g]||(b=p=0)||h.pop())if(1===d.nodeType&&++b&&d===t){c[e]=[C,p,b];break}}else if(m&&(b=p=(l=(c=(f=(d=t)[x]||(d[x]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]||[])[0]===C&&l[1]),!1===b)while(d=++p&&d&&d[g]||(b=p=0)||h.pop())if((u?d.nodeName.toLowerCase()===y:1===d.nodeType)&&++b&&(m&&((c=(f=d[x]||(d[x]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]=[C,b]),d===t))break;return(b-=i)===r||b%r==0&&b/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||oe.error("unsupported pseudo: "+e);return i[x]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?ue(function(e,n){var r,o=i(e,t),a=o.length;while(a--)e[r=P(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:ue(function(e){var t=[],n=[],r=u(e.replace($,"$1"));return r[x]?ue(function(e,t,n,i){var o,a=r(e,null,i,[]),u=e.length;while(u--)(o=a[u])&&(e[u]=!(t[u]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:ue(function(e){return function(t){return oe(e,t).length>0}}),contains:ue(function(e){return e=e.replace(Z,ee),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:ue(function(e){return V.test(e||"")||oe.error("unsupported lang: "+e),e=e.replace(Z,ee).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:pe(!1),disabled:pe(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return Y.test(e.nodeName)},input:function(e){return Q.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he(function(){return[0]}),last:he(function(e,t){return[t-1]}),eq:he(function(e,t,n){return[n<0?n+t:n]}),even:he(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function xe(e,t,n){for(var r=0,i=t.length;r-1&&(o[l]=!(a[l]=f))}}else y=we(y===a?y.splice(h,y.length):y),i?i(null,a,y,s):q.apply(a,y)})}function Te(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],u=a||r.relative[" "],s=a?1:0,c=me(function(e){return e===t},u,!0),f=me(function(e){return P(t,e)>-1},u,!0),d=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];s1&&be(d),s>1&&ye(e.slice(0,s-1).concat({value:" "===e[s-2].type?"*":""})).replace($,"$1"),n,s0,i=e.length>0,o=function(o,a,u,s,c){var f,h,v,y=0,m="0",b=o&&[],x=[],w=l,T=o||i&&r.find.TAG("*",c),E=C+=null==w?1:Math.random()||.1,N=T.length;for(c&&(l=a===p||a||c);m!==N&&null!=(f=T[m]);m++){if(i&&f){h=0,a||f.ownerDocument===p||(d(f),u=!g);while(v=e[h++])if(v(f,a||p,u)){s.push(f);break}c&&(C=E)}n&&((f=!v&&f)&&y--,o&&b.push(f))}if(y+=m,n&&m!==y){h=0;while(v=t[h++])v(b,x,a,u);if(o){if(y>0)while(m--)b[m]||x[m]||(x[m]=L.call(s));x=we(x)}q.apply(s,x),c&&!o&&x.length>0&&y+t.length>1&&oe.uniqueSort(s)}return c&&(C=E,l=w),b};return n?ue(o):o}return u=oe.compile=function(e,t){var n,r=[],i=[],o=k[e+" "];if(!o){t||(t=a(e)),n=t.length;while(n--)(o=Te(t[n]))[x]?r.push(o):i.push(o);(o=k(e,Ee(i,r))).selector=e}return o},s=oe.select=function(e,t,n,i){var o,s,l,c,f,d="function"==typeof e&&e,p=!i&&a(e=d.selector||e);if(n=n||[],1===p.length){if((s=p[0]=p[0].slice(0)).length>2&&"ID"===(l=s[0]).type&&9===t.nodeType&&g&&r.relative[s[1].type]){if(!(t=(r.find.ID(l.matches[0].replace(Z,ee),t)||[])[0]))return n;d&&(t=t.parentNode),e=e.slice(s.shift().value.length)}o=X.needsContext.test(e)?0:s.length;while(o--){if(l=s[o],r.relative[c=l.type])break;if((f=r.find[c])&&(i=f(l.matches[0].replace(Z,ee),J.test(s[0].type)&&ge(t.parentNode)||t))){if(s.splice(o,1),!(e=i.length&&ye(s)))return q.apply(n,i),n;break}}}return(d||u(e,p))(i,t,!g,n,!t||J.test(e)&&ge(t.parentNode)||t),n},n.sortStable=x.split("").sort(A).join("")===x,n.detectDuplicates=!!f,d(),n.sortDetached=se(function(e){return 1&e.compareDocumentPosition(p.createElement("fieldset"))}),se(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||le("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&se(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||le("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),se(function(e){return null==e.getAttribute("disabled")})||le(H,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),oe}(e);w.find=E,w.expr=E.selectors,w.expr[":"]=w.expr.pseudos,w.uniqueSort=w.unique=E.uniqueSort,w.text=E.getText,w.isXMLDoc=E.isXML,w.contains=E.contains,w.escapeSelector=E.escape;var N=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&w(e).is(n))break;r.push(e)}return r},k=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},A=w.expr.match.needsContext;function D(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var S=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function L(e,t,n){return g(t)?w.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?w.grep(e,function(e){return e===t!==n}):"string"!=typeof t?w.grep(e,function(e){return s.call(t,e)>-1!==n}):w.filter(t,e,n)}w.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?w.find.matchesSelector(r,e)?[r]:[]:w.find.matches(e,w.grep(t,function(e){return 1===e.nodeType}))},w.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(w(e).filter(function(){for(t=0;t1?w.uniqueSort(n):n},filter:function(e){return this.pushStack(L(this,e||[],!1))},not:function(e){return this.pushStack(L(this,e||[],!0))},is:function(e){return!!L(this,"string"==typeof e&&A.test(e)?w(e):e||[],!1).length}});var j,q=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(w.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:q.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof w?t[0]:t,w.merge(this,w.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),S.test(i[1])&&w.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(o=r.getElementById(i[2]))&&(this[0]=o,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==n.ready?n.ready(e):e(w):w.makeArray(e,this)}).prototype=w.fn,j=w(r);var O=/^(?:parents|prev(?:Until|All))/,P={children:!0,contents:!0,next:!0,prev:!0};w.fn.extend({has:function(e){var t=w(e,this),n=t.length;return this.filter(function(){for(var e=0;e-1:1===n.nodeType&&w.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?w.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?s.call(w(e),this[0]):s.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(w.uniqueSort(w.merge(this.get(),w(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function H(e,t){while((e=e[t])&&1!==e.nodeType);return e}w.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return N(e,"parentNode")},parentsUntil:function(e,t,n){return N(e,"parentNode",n)},next:function(e){return H(e,"nextSibling")},prev:function(e){return H(e,"previousSibling")},nextAll:function(e){return N(e,"nextSibling")},prevAll:function(e){return N(e,"previousSibling")},nextUntil:function(e,t,n){return N(e,"nextSibling",n)},prevUntil:function(e,t,n){return N(e,"previousSibling",n)},siblings:function(e){return k((e.parentNode||{}).firstChild,e)},children:function(e){return k(e.firstChild)},contents:function(e){return D(e,"iframe")?e.contentDocument:(D(e,"template")&&(e=e.content||e),w.merge([],e.childNodes))}},function(e,t){w.fn[e]=function(n,r){var i=w.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=w.filter(r,i)),this.length>1&&(P[e]||w.uniqueSort(i),O.test(e)&&i.reverse()),this.pushStack(i)}});var I=/[^\x20\t\r\n\f]+/g;function R(e){var t={};return w.each(e.match(I)||[],function(e,n){t[n]=!0}),t}w.Callbacks=function(e){e="string"==typeof e?R(e):w.extend({},e);var t,n,r,i,o=[],a=[],u=-1,s=function(){for(i=i||e.once,r=t=!0;a.length;u=-1){n=a.shift();while(++u-1)o.splice(n,1),n<=u&&u--}),this},has:function(e){return e?w.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||t||(o=n=""),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||s()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l};function B(e){return e}function M(e){throw e}function W(e,t,n,r){var i;try{e&&g(i=e.promise)?i.call(e).done(t).fail(n):e&&g(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}w.extend({Deferred:function(t){var n=[["notify","progress",w.Callbacks("memory"),w.Callbacks("memory"),2],["resolve","done",w.Callbacks("once memory"),w.Callbacks("once memory"),0,"resolved"],["reject","fail",w.Callbacks("once memory"),w.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},"catch":function(e){return i.then(null,e)},pipe:function(){var e=arguments;return w.Deferred(function(t){w.each(n,function(n,r){var i=g(e[r[4]])&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&g(e.promise)?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var u=this,s=arguments,l=function(){var e,l;if(!(t=o&&(r!==M&&(u=void 0,s=[e]),n.rejectWith(u,s))}};t?c():(w.Deferred.getStackHook&&(c.stackTrace=w.Deferred.getStackHook()),e.setTimeout(c))}}return w.Deferred(function(e){n[0][3].add(a(0,e,g(i)?i:B,e.notifyWith)),n[1][3].add(a(0,e,g(t)?t:B)),n[2][3].add(a(0,e,g(r)?r:M))}).promise()},promise:function(e){return null!=e?w.extend(e,i):i}},o={};return w.each(n,function(e,t){var a=t[2],u=t[5];i[t[1]]=a.add,u&&a.add(function(){r=u},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),i=o.call(arguments),a=w.Deferred(),u=function(e){return function(n){r[e]=this,i[e]=arguments.length>1?o.call(arguments):n,--t||a.resolveWith(r,i)}};if(t<=1&&(W(e,a.done(u(n)).resolve,a.reject,!t),"pending"===a.state()||g(i[n]&&i[n].then)))return a.then();while(n--)W(i[n],u(n),a.reject);return a.promise()}});var $=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;w.Deferred.exceptionHook=function(t,n){e.console&&e.console.warn&&t&&$.test(t.name)&&e.console.warn("jQuery.Deferred exception: "+t.message,t.stack,n)},w.readyException=function(t){e.setTimeout(function(){throw t})};var F=w.Deferred();w.fn.ready=function(e){return F.then(e)["catch"](function(e){w.readyException(e)}),this},w.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--w.readyWait:w.isReady)||(w.isReady=!0,!0!==e&&--w.readyWait>0||F.resolveWith(r,[w]))}}),w.ready.then=F.then;function z(){r.removeEventListener("DOMContentLoaded",z),e.removeEventListener("load",z),w.ready()}"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll?e.setTimeout(w.ready):(r.addEventListener("DOMContentLoaded",z),e.addEventListener("load",z));var _=function(e,t,n,r,i,o,a){var u=0,s=e.length,l=null==n;if("object"===b(n)){i=!0;for(u in n)_(e,t,u,n[u],!0,o,a)}else if(void 0!==r&&(i=!0,g(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(w(e),n)})),t))for(;u1,null,!0)},removeData:function(e){return this.each(function(){J.remove(this,e)})}}),w.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=K.get(e,t),n&&(!r||Array.isArray(n)?r=K.access(e,t,w.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=w.queue(e,t),r=n.length,i=n.shift(),o=w._queueHooks(e,t),a=function(){w.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return K.get(e,n)||K.access(e,n,{empty:w.Callbacks("once memory").add(function(){K.remove(e,[t+"queue",n])})})}}),w.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length\x20\t\r\n\f]+)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&D(e,t)?w.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n-1)i&&i.push(o);else if(l=w.contains(o.ownerDocument,o),a=ve(f.appendChild(o),"script"),l&&ye(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}!function(){var e=r.createDocumentFragment().appendChild(r.createElement("div")),t=r.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),h.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var xe=r.documentElement,we=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Te=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function Ne(){return!1}function ke(){try{return r.activeElement}catch(e){}}function Ae(e,t,n,r,i,o){var a,u;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(u in t)Ae(e,u,n,r,t[u],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Ne;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,u,s,l,c,f,d,p,h,g,v=K.get(e);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(xe,i),n.guid||(n.guid=w.guid++),(s=v.events)||(s=v.events={}),(a=v.handle)||(a=v.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(I)||[""]).length;while(l--)p=g=(u=Te.exec(t[l])||[])[1],h=(u[2]||"").split(".").sort(),p&&(f=w.event.special[p]||{},p=(i?f.delegateType:f.bindType)||p,f=w.event.special[p]||{},c=w.extend({type:p,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(d=s[p])||((d=s[p]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(p,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?d.splice(d.delegateCount++,0,c):d.push(c),w.event.global[p]=!0)}},remove:function(e,t,n,r,i){var o,a,u,s,l,c,f,d,p,h,g,v=K.hasData(e)&&K.get(e);if(v&&(s=v.events)){l=(t=(t||"").match(I)||[""]).length;while(l--)if(u=Te.exec(t[l])||[],p=g=u[1],h=(u[2]||"").split(".").sort(),p){f=w.event.special[p]||{},d=s[p=(r?f.delegateType:f.bindType)||p]||[],u=u[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=d.length;while(o--)c=d[o],!i&&g!==c.origType||n&&n.guid!==c.guid||u&&!u.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(d.splice(o,1),c.selector&&d.delegateCount--,f.remove&&f.remove.call(e,c));a&&!d.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||w.removeEvent(e,p,v.handle),delete s[p])}else for(p in s)w.event.remove(e,p+t[l],n,r,!0);w.isEmptyObject(s)&&K.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,u,s=new Array(arguments.length),l=(K.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(s[0]=t,n=1;n=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&u.push({elem:l,handlers:o})}return l=this,s\x20\t\r\n\f]*)[^>]*)\/>/gi,Se=/\s*$/g;function qe(e,t){return D(e,"table")&&D(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function Oe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Pe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function He(e,t){var n,r,i,o,a,u,s,l;if(1===t.nodeType){if(K.hasData(e)&&(o=K.access(e),a=K.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n1&&"string"==typeof v&&!h.checkClone&&Le.test(v))return e.each(function(i){var o=e.eq(i);y&&(t[0]=v.call(this,i,o.html())),Re(o,t,n,r)});if(d&&(i=be(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(s=(u=w.map(ve(i,"script"),Oe)).length;f")},clone:function(e,t,n){var r,i,o,a,u=e.cloneNode(!0),s=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ve(u),r=0,i=(o=ve(e)).length;r0&&ye(a,!s&&ve(e,"script")),u},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[K.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[K.expando]=void 0}n[J.expando]&&(n[J.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Be(this,e,!0)},remove:function(e){return Be(this,e)},text:function(e){return _(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||qe(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=qe(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ve(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return _(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Se.test(e)&&!ge[(pe.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n=0&&(s+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-s-u-.5))),s}function et(e,t,n){var r=We(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(Me.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,u=Q(t),s=Ue.test(t),l=e.style;if(s||(t=Ke(u)),a=w.cssHooks[t]||w.cssHooks[u],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=se(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[u]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(s?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,u=Q(t);return Ue.test(t)||(t=Ke(u)),(a=w.cssHooks[t]||w.cssHooks[u])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Xe&&(i=Xe[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!_e.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):ue(e,Ve,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=We(e),a="border-box"===w.css(e,"boxSizing",!1,o),u=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(u-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),u&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Je(e,n,u)}}}),w.cssHooks.marginLeft=ze(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-ue(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Je)}),w.fn.extend({css:function(e,t){return _(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=We(e),i=t.length;a1)}}),w.fn.delay=function(t,n){return t=w.fx?w.fx.speeds[t]||t:t,n=n||"fx",this.queue(n,function(n,r){var i=e.setTimeout(n,t);r.stop=function(){e.clearTimeout(i)}})},function(){var e=r.createElement("input"),t=r.createElement("select").appendChild(r.createElement("option"));e.type="checkbox",h.checkOn=""!==e.value,h.optSelected=t.selected,(e=r.createElement("input")).value="t",e.type="radio",h.radioValue="t"===e.value}();var tt,nt=w.expr.attrHandle;w.fn.extend({attr:function(e,t){return _(this,w.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?tt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&D(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(I);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),tt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=nt[t]||w.find.attr;nt[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=nt[a],nt[a]=i,i=null!=n(e,t,r)?a:null,nt[a]=o),i}});var rt=/^(?:input|select|textarea|button)$/i,it=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return _(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):rt.test(e.nodeName)||it.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function ot(e){return(e.match(I)||[]).join(" ")}function at(e){return e.getAttribute&&e.getAttribute("class")||""}function ut(e){return Array.isArray(e)?e:"string"==typeof e?e.match(I)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,u,s=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,at(this)))});if((t=ut(e)).length)while(n=this[s++])if(i=at(n),r=1===n.nodeType&&" "+ot(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(u=ot(r))&&n.setAttribute("class",u)}return this},removeClass:function(e){var t,n,r,i,o,a,u,s=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,at(this)))});if(!arguments.length)return this.attr("class","");if((t=ut(e)).length)while(n=this[s++])if(i=at(n),r=1===n.nodeType&&" "+ot(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(u=ot(r))&&n.setAttribute("class",u)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,at(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=ut(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=at(this))&&K.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":K.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+ot(at(n))+" ").indexOf(t)>-1)return!0;return!1}});var st=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(st,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:ot(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,u=a?null:[],s=a?o+1:i.length;for(r=o<0?s:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var lt=/^(?:focusinfocus|focusoutblur)$/,ct=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,u,s,l,c,d,p,h,y=[i||r],m=f.call(t,"type")?t.type:t,b=f.call(t,"namespace")?t.namespace.split("."):[];if(u=h=s=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!lt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(b=m.split(".")).shift(),b.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=b.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+b.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),p=w.event.special[m]||{},o||!p.trigger||!1!==p.trigger.apply(i,n))){if(!o&&!p.noBubble&&!v(i)){for(l=p.delegateType||m,lt.test(l+m)||(u=u.parentNode);u;u=u.parentNode)y.push(u),s=u;s===(i.ownerDocument||r)&&y.push(s.defaultView||s.parentWindow||e)}a=0;while((u=y[a++])&&!t.isPropagationStopped())h=u,t.type=a>1?l:p.bindType||m,(d=(K.get(u,"events")||{})[t.type]&&K.get(u,"handle"))&&d.apply(u,n),(d=c&&u[c])&&d.apply&&Y(u)&&(t.result=d.apply(u,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||p._default&&!1!==p._default.apply(y.pop(),n)||!Y(i)||c&&g(i[m])&&!v(i)&&((s=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,ct),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,ct),w.event.triggered=void 0,s&&(i[c]=s)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=K.access(r,t);i||r.addEventListener(e,n,!0),K.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=K.access(r,t)-1;i?K.access(r,t,i):(r.removeEventListener(e,n,!0),K.remove(r,t))}}});var ft=/\[\]$/,dt=/\r?\n/g,pt=/^(?:submit|button|image|reset|file)$/i,ht=/^(?:input|select|textarea|keygen)/i;function gt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||ft.test(e)?r(e,i):gt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==b(t))r(e,t);else for(i in t)gt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)gt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&ht.test(this.nodeName)&&!pt.test(e)&&(this.checked||!de.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(dt,"\r\n")}}):{name:t.name,value:n.replace(dt,"\r\n")}}).get()}}),w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},h.createHTMLDocument=function(){var e=r.implementation.createHTMLDocument("").body;return e.innerHTML="

",2===e.childNodes.length}(),w.parseHTML=function(e,t,n){if("string"!=typeof e)return[];"boolean"==typeof t&&(n=t,t=!1);var i,o,a;return t||(h.createHTMLDocument?((i=(t=r.implementation.createHTMLDocument("")).createElement("base")).href=r.location.href,t.head.appendChild(i)):t=r),o=S.exec(e),a=!n&&[],o?[t.createElement(o[1])]:(o=be([e],t,a),a&&a.length&&w(a).remove(),w.merge([],o.childNodes))},w.offset={setOffset:function(e,t,n){var r,i,o,a,u,s,l,c=w.css(e,"position"),f=w(e),d={};"static"===c&&(e.style.position="relative"),u=f.offset(),o=w.css(e,"top"),s=w.css(e,"left"),(l=("absolute"===c||"fixed"===c)&&(o+s).indexOf("auto")>-1)?(a=(r=f.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(s)||0),g(t)&&(t=t.call(e,n,w.extend({},u))),null!=t.top&&(d.top=t.top-u.top+a),null!=t.left&&(d.left=t.left-u.left+i),"using"in t?t.using.call(e,d):f.css(d)}},w.fn.extend({offset:function(e){if(arguments.length)return void 0===e?this:this.each(function(t){w.offset.setOffset(this,e,t)});var t,n,r=this[0];if(r)return r.getClientRects().length?(t=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:t.top+n.pageYOffset,left:t.left+n.pageXOffset}):{top:0,left:0}},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===w.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===w.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=w(e).offset()).top+=w.css(e,"borderTopWidth",!0),i.left+=w.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-w.css(r,"marginTop",!0),left:t.left-i.left-w.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===w.css(e,"position"))e=e.offsetParent;return e||xe})}}),w.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,t){var n="pageYOffset"===t;w.fn[e]=function(r){return _(this,function(e,r,i){var o;if(v(e)?o=e:9===e.nodeType&&(o=e.defaultView),void 0===i)return o?o[t]:e[r];o?o.scrollTo(n?o.pageXOffset:i,n?i:o.pageYOffset):e[r]=i},e,r,arguments.length)}}),w.each(["top","left"],function(e,t){w.cssHooks[t]=ze(h.pixelPosition,function(e,n){if(n)return n=Fe(e,t),Me.test(n)?w(e).position()[t]+"px":n})}),w.each({Height:"height",Width:"width"},function(e,t){w.each({padding:"inner"+e,content:t,"":"outer"+e},function(n,r){w.fn[r]=function(i,o){var a=arguments.length&&(n||"boolean"!=typeof i),u=n||(!0===i||!0===o?"margin":"border");return _(this,function(t,n,i){var o;return v(t)?0===r.indexOf("outer")?t["inner"+e]:t.document.documentElement["client"+e]:9===t.nodeType?(o=t.documentElement,Math.max(t.body["scroll"+e],o["scroll"+e],t.body["offset"+e],o["offset"+e],o["client"+e])):void 0===i?w.css(t,n,u):w.style(t,n,i,u)},t,a?i:void 0,a)}})}),w.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,t){w.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),w.fn.extend({hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),w.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)}}),w.proxy=function(e,t){var n,r,i;if("string"==typeof t&&(n=e[t],t=e,e=n),g(e))return r=o.call(arguments,2),i=function(){return e.apply(t||this,r.concat(o.call(arguments)))},i.guid=e.guid=e.guid||w.guid++,i},w.holdReady=function(e){e?w.readyWait++:w.ready(!0)},w.isArray=Array.isArray,w.parseJSON=JSON.parse,w.nodeName=D,w.isFunction=g,w.isWindow=v,w.camelCase=Q,w.type=b,w.now=Date.now,w.isNumeric=function(e){var t=w.type(e);return("number"===t||"string"===t)&&!isNaN(e-parseFloat(e))},"function"==typeof define&&define.amd&&define("jquery",[],function(){return w});var vt=e.jQuery,yt=e.$;return w.noConflict=function(t){return e.$===w&&(e.$=yt),t&&e.jQuery===w&&(e.jQuery=vt),w},t||(e.jQuery=e.$=w),w}); diff --git a/dashboard/static/js.cookie-v3.0.0-beta.0.min.js b/dashboard/static/js.cookie-v3.0.0-beta.0.min.js new file mode 100644 index 0000000000..f8d6c2dad5 --- /dev/null +++ b/dashboard/static/js.cookie-v3.0.0-beta.0.min.js @@ -0,0 +1,2 @@ +/*! js-cookie v3.0.0-beta.0 | MIT */ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self,function(){var t=e.Cookies,o=e.Cookies=n();o.noConflict=function(){return e.Cookies=t,o}}())}(this,function(){"use strict";function e(){for(var e={},n=0;n=o.clientWidth&&n>=o.clientHeight}),l=0a[e]&&!t.escapeWithReference&&(n=Q(f[o],a[e]-('right'===e?f.width:f.height))),le({},o,n)}};return l.forEach(function(e){var t=-1===['left','top'].indexOf(e)?'secondary':'primary';f=fe({},f,m[t](e))}),e.offsets.popper=f,e},priority:['left','right','top','bottom'],padding:5,boundariesElement:'scrollParent'},keepTogether:{order:400,enabled:!0,fn:function(e){var t=e.offsets,o=t.popper,n=t.reference,i=e.placement.split('-')[0],r=Z,p=-1!==['top','bottom'].indexOf(i),s=p?'right':'bottom',d=p?'left':'top',a=p?'width':'height';return o[s]r(n[s])&&(e.offsets.popper[d]=r(n[s])),e}},arrow:{order:500,enabled:!0,fn:function(e,o){var n;if(!K(e.instance.modifiers,'arrow','keepTogether'))return e;var i=o.element;if('string'==typeof i){if(i=e.instance.popper.querySelector(i),!i)return e;}else if(!e.instance.popper.contains(i))return console.warn('WARNING: `arrow.element` must be child of its popper element!'),e;var r=e.placement.split('-')[0],p=e.offsets,s=p.popper,d=p.reference,a=-1!==['left','right'].indexOf(r),l=a?'height':'width',f=a?'Top':'Left',m=f.toLowerCase(),h=a?'left':'top',c=a?'bottom':'right',u=S(i)[l];d[c]-us[c]&&(e.offsets.popper[m]+=d[m]+u-s[c]),e.offsets.popper=g(e.offsets.popper);var b=d[m]+d[l]/2-u/2,w=t(e.instance.popper),y=parseFloat(w['margin'+f],10),E=parseFloat(w['border'+f+'Width'],10),v=b-e.offsets.popper[m]-y-E;return v=ee(Q(s[l]-u,v),0),e.arrowElement=i,e.offsets.arrow=(n={},le(n,m,$(v)),le(n,h,''),n),e},element:'[x-arrow]'},flip:{order:600,enabled:!0,fn:function(e,t){if(W(e.instance.modifiers,'inner'))return e;if(e.flipped&&e.placement===e.originalPlacement)return e;var o=v(e.instance.popper,e.instance.reference,t.padding,t.boundariesElement,e.positionFixed),n=e.placement.split('-')[0],i=T(n),r=e.placement.split('-')[1]||'',p=[];switch(t.behavior){case ge.FLIP:p=[n,i];break;case ge.CLOCKWISE:p=G(n);break;case ge.COUNTERCLOCKWISE:p=G(n,!0);break;default:p=t.behavior;}return p.forEach(function(s,d){if(n!==s||p.length===d+1)return e;n=e.placement.split('-')[0],i=T(n);var a=e.offsets.popper,l=e.offsets.reference,f=Z,m='left'===n&&f(a.right)>f(l.left)||'right'===n&&f(a.left)f(l.top)||'bottom'===n&&f(a.top)f(o.right),g=f(a.top)f(o.bottom),b='left'===n&&h||'right'===n&&c||'top'===n&&g||'bottom'===n&&u,w=-1!==['top','bottom'].indexOf(n),y=!!t.flipVariations&&(w&&'start'===r&&h||w&&'end'===r&&c||!w&&'start'===r&&g||!w&&'end'===r&&u);(m||b||y)&&(e.flipped=!0,(m||b)&&(n=p[d+1]),y&&(r=z(r)),e.placement=n+(r?'-'+r:''),e.offsets.popper=fe({},e.offsets.popper,D(e.instance.popper,e.offsets.reference,e.placement)),e=P(e.instance.modifiers,e,'flip'))}),e},behavior:'flip',padding:5,boundariesElement:'viewport'},inner:{order:700,enabled:!1,fn:function(e){var t=e.placement,o=t.split('-')[0],n=e.offsets,i=n.popper,r=n.reference,p=-1!==['left','right'].indexOf(o),s=-1===['top','left'].indexOf(o);return i[p?'left':'top']=r[o]-(s?i[p?'width':'height']:0),e.placement=T(t),e.offsets.popper=g(i),e}},hide:{order:800,enabled:!0,fn:function(e){if(!K(e.instance.modifiers,'hide','preventOverflow'))return e;var t=e.offsets.reference,o=C(e.instance.modifiers,function(e){return'preventOverflow'===e.name}).boundaries;if(t.bottomo.right||t.top>o.bottom||t.rightwindow.devicePixelRatio||!me),c='bottom'===o?'top':'bottom',g='right'===n?'left':'right',b=H('transform');if(d='bottom'==c?'HTML'===l.nodeName?-l.clientHeight+h.bottom:-f.height+h.bottom:h.top,s='right'==g?'HTML'===l.nodeName?-l.clientWidth+h.right:-f.width+h.right:h.left,a&&b)m[b]='translate3d('+s+'px, '+d+'px, 0)',m[c]=0,m[g]=0,m.willChange='transform';else{var w='bottom'==c?-1:1,y='right'==g?-1:1;m[c]=d*w,m[g]=s*y,m.willChange=c+', '+g}var E={"x-placement":e.placement};return e.attributes=fe({},E,e.attributes),e.styles=fe({},m,e.styles),e.arrowStyles=fe({},e.offsets.arrow,e.arrowStyles),e},gpuAcceleration:!0,x:'bottom',y:'right'},applyStyle:{order:900,enabled:!0,fn:function(e){return j(e.instance.popper,e.styles),V(e.instance.popper,e.attributes),e.arrowElement&&Object.keys(e.arrowStyles).length&&j(e.arrowElement,e.arrowStyles),e},onLoad:function(e,t,o,n,i){var r=L(i,t,e,o.positionFixed),p=O(o.placement,r,t,e,o.modifiers.flip.boundariesElement,o.modifiers.flip.padding);return t.setAttribute('x-placement',p),j(t,{position:o.positionFixed?'fixed':'absolute'}),o},gpuAcceleration:void 0}}},ue}); +//# sourceMappingURL=popper.min.js.map diff --git a/dashboard/static/renderjson-b31d877.js b/dashboard/static/renderjson-b31d877.js new file mode 100644 index 0000000000..3810cf288b --- /dev/null +++ b/dashboard/static/renderjson-b31d877.js @@ -0,0 +1,189 @@ +// Copyright © 2013-2014 David Caldwell +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +// Usage +// ----- +// The module exports one entry point, the `renderjson()` function. It takes in +// the JSON you want to render as a single argument and returns an HTML +// element. +// +// Options +// ------- +// renderjson.set_icons("+", "-") +// This Allows you to override the disclosure icons. +// +// renderjson.set_show_to_level(level) +// Pass the number of levels to expand when rendering. The default is 0, which +// starts with everything collapsed. As a special case, if level is the string +// "all" then it will start with everything expanded. +// +// renderjson.set_max_string_length(length) +// Strings will be truncated and made expandable if they are longer than +// `length`. As a special case, if `length` is the string "none" then +// there will be no truncation. The default is "none". +// +// renderjson.set_sort_objects(sort_bool) +// Sort objects by key (default: false) +// +// Theming +// ------- +// The HTML output uses a number of classes so that you can theme it the way +// you'd like: +// .disclosure ("⊕", "⊖") +// .syntax (",", ":", "{", "}", "[", "]") +// .string (includes quotes) +// .number +// .boolean +// .key (object key) +// .keyword ("null", "undefined") +// .object.syntax ("{", "}") +// .array.syntax ("[", "]") + +var module; +(module||{}).exports = renderjson = (function() { + var themetext = function(/* [class, text]+ */) { + var spans = []; + while (arguments.length) + spans.push(append(span(Array.prototype.shift.call(arguments)), + text(Array.prototype.shift.call(arguments)))); + return spans; + }; + var append = function(/* el, ... */) { + var el = Array.prototype.shift.call(arguments); + for (var a=0; a 0) + show(); + return el; + }; + + if (json === null) return themetext(null, my_indent, "keyword", "null"); + if (json === void 0) return themetext(null, my_indent, "keyword", "undefined"); + + if (typeof(json) == "string" && json.length > max_string) + return disclosure('"', json.substr(0,max_string)+" ...", '"', "string", function () { + return append(span("string"), themetext(null, my_indent, "string", JSON.stringify(json))); + }); + + if (typeof(json) != "object") // Strings, numbers and bools + return themetext(null, my_indent, typeof(json), JSON.stringify(json)); + + if (json.constructor == Array) { + if (json.length == 0) return themetext(null, my_indent, "array syntax", "[]"); + + return disclosure("[", " ... ", "]", "array", function () { + var as = append(span("array"), themetext("array syntax", "[", null, "\n")); + for (var i=0; i { + it("initializes", () => { + expect(new ProxyDashboard()).toBeTruthy(); + }); +}); diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000000..0f1aacde57 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "sourceMap": true, + "target": "es5" + }, + "include": [ + "src/**/*" + ] +} diff --git a/helper/Procfile b/helper/Procfile new file mode 100644 index 0000000000..bcfb16a577 --- /dev/null +++ b/helper/Procfile @@ -0,0 +1,9 @@ +# proxy.py +# ~~~~~~~~ +# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# +# :copyright: (c) 2013-present by Abhinav Singh and contributors. +# :license: BSD, see LICENSE for more details. +# +# See https://devcenter.heroku.com/articles/procfile +web: python3 proxy.py --hostname 0.0.0.0 --port $PORT diff --git a/chrome_with_proxy.sh b/helper/chrome_with_proxy.sh similarity index 89% rename from chrome_with_proxy.sh rename to helper/chrome_with_proxy.sh index 66e00cb87d..8f3cf3d242 100755 --- a/chrome_with_proxy.sh +++ b/helper/chrome_with_proxy.sh @@ -11,7 +11,7 @@ # ./chrome_with_proxy PROXY_PY_ADDR=$1 -if [ -z "$PROXY_PY_ADDR" ]; then +if [[ -z "$PROXY_PY_ADDR" ]]; then PROXY_PY_ADDR="localhost:8899" fi @@ -19,6 +19,6 @@ fi --no-first-run \ --no-default-browser-check \ --user-data-dir="$(mktemp -d -t 'chrome-remote_data_dir')" \ - --proxy-server=$PROXY_PY_ADDR \ + --proxy-server=${PROXY_PY_ADDR} \ --ignore-urlfetcher-cert-requests \ --ignore-certificate-errors diff --git a/fluentd.conf b/helper/fluentd.conf similarity index 77% rename from fluentd.conf rename to helper/fluentd.conf index 13e6ed36a9..dc34a9557f 100644 --- a/fluentd.conf +++ b/helper/fluentd.conf @@ -1,3 +1,10 @@ +# proxy.py +# ~~~~~~~~ +# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# +# :copyright: (c) 2013-present by Abhinav Singh and contributors. +# :license: BSD, see LICENSE for more details. +# # google-fluentd (Stackdriver) log input configuration file # # 1. Copy this configuration file as proxy.py.conf under: diff --git a/monitor_open_files.sh b/helper/monitor_open_files.sh similarity index 100% rename from monitor_open_files.sh rename to helper/monitor_open_files.sh diff --git a/proxy.pac b/helper/proxy.pac similarity index 100% rename from proxy.pac rename to helper/proxy.pac diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index ed5e6f61d2..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "proxy.py", - "version": "1.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "dev": true - }, - "chrome-devtools-frontend": { - "version": "1.0.702145", - "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.702145.tgz", - "integrity": "sha512-/GtRYVUWETCifkvlfYhrJkPg/SrTVcMwNNj7Q2Ax0LELwCil7AUF5QYY/jMMjv5QYLHSgSzWX0yICK3qqUDkhw==", - "dev": true - }, - "chrome-remote-interface": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.28.0.tgz", - "integrity": "sha512-md2qSn6rc/fADlN+Blk2UWNg0SGPYjH2s68piaPN9e62HItKm6uWeXXHh0+28Bq10oaWw8fzNAm1itDFJ+nS4w==", - "dev": true, - "requires": { - "commander": "2.11.x", - "ws": "^6.1.0" - } - }, - "commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "dev": true - }, - "ncp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", - "dev": true - }, - "ws": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", - "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", - "dev": true, - "requires": { - "async-limiter": "~1.0.0" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 890a808cab..0000000000 --- a/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "proxy.py", - "version": "1.0.1", - "description": "Lightweight, Programmable, TLS interceptor Proxy for HTTP(S), HTTP2, WebSockets protocols in a single Python file.", - "main": "proxy.js", - "scripts": { - "start": "node proxy.js", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/abhinavsingh/proxy.py.git" - }, - "author": "Abhinav Singh", - "license": "BSD-3-Clause", - "bugs": { - "url": "https://github.com/abhinavsingh/proxy.py/issues" - }, - "homepage": "https://github.com/abhinavsingh/proxy.py#readme", - "devDependencies": { - "chrome-devtools-frontend": "^1.0.702145", - "chrome-remote-interface": "^0.28.0", - "ncp": "^2.0.0" - } -} diff --git a/plugin_examples.py b/plugin_examples.py deleted file mode 100644 index 80069649a1..0000000000 --- a/plugin_examples.py +++ /dev/null @@ -1,305 +0,0 @@ -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import json -import os -import tempfile -import time -from typing import Optional, BinaryIO, List, Tuple -from urllib import parse as urlparse - -import proxy -from proxy import HttpParser - - -class ShortLinkPlugin(proxy.HttpProxyBasePlugin): - """Add support for short links in your favorite browsers / applications. - - Enable ShortLinkPlugin and speed up your daily browsing experience. - - Example: - * f/ for facebook.com - * g/ for google.com - * t/ for twitter.com - * y/ for youtube.com - * proxy/ for proxy.py internal web servers. - Customize map below for your taste and need. - - Paths are also preserved. E.g. t/imoracle will - resolve to http://twitter.com/imoracle. - """ - - SHORT_LINKS = { - b'a': b'amazon.com', - b'i': b'instagram.com', - b'l': b'linkedin.com', - b'f': b'facebook.com', - b'g': b'google.com', - b't': b'twitter.com', - b'w': b'web.whatsapp.com', - b'y': b'youtube.com', - b'proxy': b'localhost:8899', - } - - def before_upstream_connection(self, request: HttpParser) -> Optional[HttpParser]: - if request.host and request.host != b'localhost' and proxy.DOT not in request.host: - # Avoid connecting to upstream - return None - return request - - def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: - if request.host and request.host != b'localhost' and proxy.DOT not in request.host: - if request.host in self.SHORT_LINKS: - path = proxy.SLASH if not request.path else request.path - self.client.queue(proxy.build_http_response( - proxy.httpStatusCodes.SEE_OTHER, reason=b'See Other', - headers={ - b'Location': b'http://' + self.SHORT_LINKS[request.host] + path, - b'Content-Length': b'0', - b'Connection': b'close', - } - )) - else: - self.client.queue(proxy.build_http_response( - proxy.httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', - headers={ - b'Content-Length': b'0', - b'Connection': b'close', - } - )) - return None - return request - - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return chunk - - def on_upstream_connection_close(self) -> None: - pass - - -class ModifyPostDataPlugin(proxy.HttpProxyBasePlugin): - """Modify POST request body before sending to upstream server.""" - - MODIFIED_BODY = b'{"key": "modified"}' - - def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - if request.method == proxy.httpMethods.POST: - request.body = ModifyPostDataPlugin.MODIFIED_BODY - # Update Content-Length header only when request is NOT chunked encoded - if not request.is_chunked_encoded(): - request.add_header(b'Content-Length', proxy.bytes_(len(request.body))) - # Enforce content-type json - if request.has_header(b'Content-Type'): - request.del_header(b'Content-Type') - request.add_header(b'Content-Type', b'application/json') - return request - - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return chunk - - def on_upstream_connection_close(self) -> None: - pass - - -class ProposedRestApiPlugin(proxy.HttpProxyBasePlugin): - """Mock responses for your upstream REST API. - - Used to test and develop client side applications - without need of an actual upstream REST API server. - - Returns proposed REST API mock responses to the client - without establishing upstream connection. - - Note: This plugin won't work if your client is making - HTTPS connection to api.example.com. - """ - - API_SERVER = b'api.example.com' - - REST_API_SPEC = { - b'/v1/users/': { - 'count': 2, - 'next': None, - 'previous': None, - 'results': [ - { - 'email': 'you@example.com', - 'groups': [], - 'url': proxy.text_(API_SERVER) + '/v1/users/1/', - 'username': 'admin', - }, - { - 'email': 'someone@example.com', - 'groups': [], - 'url': proxy.text_(API_SERVER) + '/v1/users/2/', - 'username': 'someone', - }, - ] - }, - } - - def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - # Return None to disable establishing connection to upstream - # Most likely our api.example.com won't even exist under development scenario - return None - - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - if request.host != self.API_SERVER: - return request - assert request.path - if request.path in self.REST_API_SPEC: - self.client.queue(proxy.build_http_response( - proxy.httpStatusCodes.OK, - reason=b'OK', - headers={b'Content-Type': b'application/json'}, - body=proxy.bytes_(json.dumps( - self.REST_API_SPEC[request.path])) - )) - else: - self.client.queue(proxy.build_http_response( - proxy.httpStatusCodes.NOT_FOUND, - reason=b'NOT FOUND', body=b'Not Found' - )) - return None - - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return chunk - - def on_upstream_connection_close(self) -> None: - pass - - -class RedirectToCustomServerPlugin(proxy.HttpProxyBasePlugin): - """Modifies client request to redirect all incoming requests to a fixed server address.""" - - UPSTREAM_SERVER = b'http://localhost:8899/' - - def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - # Redirect all non-https requests to inbuilt WebServer. - if request.method != proxy.httpMethods.CONNECT: - request.set_url(self.UPSTREAM_SERVER) - # Update Host header too, otherwise upstream can reject our request - if request.has_header(b'Host'): - request.del_header(b'Host') - request.add_header(b'Host', urlparse.urlsplit(self.UPSTREAM_SERVER).netloc) - return request - - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return chunk - - def on_upstream_connection_close(self) -> None: - pass - - -class FilterByUpstreamHostPlugin(proxy.HttpProxyBasePlugin): - """Drop traffic by inspecting upstream host.""" - - FILTERED_DOMAINS = [b'google.com', b'www.google.com'] - - def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - if request.host in self.FILTERED_DOMAINS: - raise proxy.HttpRequestRejected( - status_code=proxy.httpStatusCodes.I_AM_A_TEAPOT, reason=b'I\'m a tea pot') - return request - - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return chunk - - def on_upstream_connection_close(self) -> None: - pass - - -class CacheResponsesPlugin(proxy.HttpProxyBasePlugin): - """Caches Upstream Server Responses.""" - - CACHE_DIR = tempfile.gettempdir() - - def __init__( - self, - config: proxy.ProtocolConfig, - client: proxy.TcpClientConnection) -> None: - super().__init__(config, client) - self.cache_file_path: Optional[str] = None - self.cache_file: Optional[BinaryIO] = None - - def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - # Ideally should only create file if upstream connection succeeds. - self.cache_file_path = os.path.join( - self.CACHE_DIR, - '%s-%s.txt' % (proxy.text_(request.host), str(time.time()))) - self.cache_file = open(self.cache_file_path, "wb") - return request - - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - - def handle_upstream_chunk(self, - chunk: bytes) -> bytes: - if self.cache_file: - self.cache_file.write(chunk) - return chunk - - def on_upstream_connection_close(self) -> None: - if self.cache_file: - self.cache_file.close() - proxy.logger.info('Cached response at %s', self.cache_file_path) - - -class ManInTheMiddlePlugin(proxy.HttpProxyBasePlugin): - """Modifies upstream server responses.""" - - def before_upstream_connection(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - - def handle_client_request(self, request: proxy.HttpParser) -> Optional[proxy.HttpParser]: - return request - - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return proxy.build_http_response( - proxy.httpStatusCodes.OK, - reason=b'OK', body=b'Hello from man in the middle') - - def on_upstream_connection_close(self) -> None: - pass - - -class WebServerPlugin(proxy.HttpWebServerBasePlugin): - """Demonstration of inbuilt web server routing via plugin.""" - - def routes(self) -> List[Tuple[int, bytes]]: - return [ - (proxy.httpProtocolTypes.HTTP, b'/http-route-example'), - (proxy.httpProtocolTypes.HTTPS, b'/https-route-example'), - (proxy.httpProtocolTypes.WEBSOCKET, b'/ws-route-example'), - ] - - def handle_request(self, request: proxy.HttpParser) -> None: - if request.path == b'/http-route-example': - self.client.queue(proxy.build_http_response( - proxy.httpStatusCodes.OK, body=b'HTTP route response')) - elif request.path == b'/https-route-example': - self.client.queue(proxy.build_http_response( - proxy.httpStatusCodes.OK, body=b'HTTPS route response')) - - def on_websocket_open(self) -> None: - proxy.logger.info('Websocket open') - - def on_websocket_message(self, frame: proxy.WebsocketFrame) -> None: - proxy.logger.info(frame.data) - - def on_websocket_close(self) -> None: - proxy.logger.info('Websocket close') diff --git a/plugin_examples/__init__.py b/plugin_examples/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/plugin_examples/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/plugin_examples/cache_responses.py b/plugin_examples/cache_responses.py new file mode 100644 index 0000000000..380cedfc69 --- /dev/null +++ b/plugin_examples/cache_responses.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import tempfile +import time +import logging +from typing import Optional, BinaryIO + +from proxy.common.flags import Flags +from proxy.core.connection import TcpClientConnection +from proxy.http.parser import HttpParser +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.common.utils import text_ + +logger = logging.getLogger(__name__) + + +class CacheResponsesPlugin(HttpProxyBasePlugin): + """Caches Upstream Server Responses.""" + + CACHE_DIR = tempfile.gettempdir() + + def __init__( + self, + config: Flags, + client: TcpClientConnection) -> None: + super().__init__(config, client) + self.cache_file_path: Optional[str] = None + self.cache_file: Optional[BinaryIO] = None + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + # Ideally should only create file if upstream connection succeeds. + self.cache_file_path = os.path.join( + self.CACHE_DIR, + '%s-%s.txt' % (text_(request.host), str(time.time()))) + self.cache_file = open(self.cache_file_path, "wb") + return request + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_upstream_chunk(self, + chunk: bytes) -> bytes: + if self.cache_file: + self.cache_file.write(chunk) + return chunk + + def on_upstream_connection_close(self) -> None: + if self.cache_file: + self.cache_file.close() + logger.info('Cached response at %s', self.cache_file_path) diff --git a/plugin_examples/filter_by_upstream.py b/plugin_examples/filter_by_upstream.py new file mode 100644 index 0000000000..09b55bd39a --- /dev/null +++ b/plugin_examples/filter_by_upstream.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional + +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.exception import HttpRequestRejected +from proxy.http.parser import HttpParser +from proxy.http.codes import httpStatusCodes + + +class FilterByUpstreamHostPlugin(HttpProxyBasePlugin): + """Drop traffic by inspecting upstream host.""" + + FILTERED_DOMAINS = [b'google.com', b'www.google.com'] + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + if request.host in self.FILTERED_DOMAINS: + raise HttpRequestRejected( + status_code=httpStatusCodes.I_AM_A_TEAPOT, reason=b'I\'m a tea pot', + headers={ + b'Connection': b'close', + } + ) + return request + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_upstream_connection_close(self) -> None: + pass diff --git a/plugin_examples/man_in_the_middle.py b/plugin_examples/man_in_the_middle.py new file mode 100644 index 0000000000..2f803fc0e0 --- /dev/null +++ b/plugin_examples/man_in_the_middle.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional + +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.parser import HttpParser +from proxy.http.codes import httpStatusCodes +from proxy.common.utils import build_http_response + + +class ManInTheMiddlePlugin(HttpProxyBasePlugin): + """Modifies upstream server responses.""" + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') + + def on_upstream_connection_close(self) -> None: + pass diff --git a/plugin_examples/mock_rest_api.py b/plugin_examples/mock_rest_api.py new file mode 100644 index 0000000000..615ca880cb --- /dev/null +++ b/plugin_examples/mock_rest_api.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import json +from typing import Optional + +from proxy.http.parser import HttpParser +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.codes import httpStatusCodes +from proxy.common.utils import bytes_, build_http_response, text_ + + +class ProposedRestApiPlugin(HttpProxyBasePlugin): + """Mock responses for your upstream REST API. + + Used to test and develop client side applications + without need of an actual upstream REST API server. + + Returns proposed REST API mock responses to the client + without establishing upstream connection. + + Note: This plugin won't work if your client is making + HTTPS connection to api.example.com. + """ + + API_SERVER = b'api.example.com' + + REST_API_SPEC = { + b'/v1/users/': { + 'count': 2, + 'next': None, + 'previous': None, + 'results': [ + { + 'email': 'you@example.com', + 'groups': [], + 'url': text_(API_SERVER) + '/v1/users/1/', + 'username': 'admin', + }, + { + 'email': 'someone@example.com', + 'groups': [], + 'url': text_(API_SERVER) + '/v1/users/2/', + 'username': 'someone', + }, + ] + }, + } + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + # Return None to disable establishing connection to upstream + # Most likely our api.example.com won't even exist under development + # scenario + return None + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + if request.host != self.API_SERVER: + return request + assert request.path + if request.path in self.REST_API_SPEC: + self.client.queue(build_http_response( + httpStatusCodes.OK, + reason=b'OK', + headers={b'Content-Type': b'application/json'}, + body=bytes_(json.dumps( + self.REST_API_SPEC[request.path])) + )) + else: + self.client.queue(build_http_response( + httpStatusCodes.NOT_FOUND, + reason=b'NOT FOUND', body=b'Not Found' + )) + return None + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_upstream_connection_close(self) -> None: + pass diff --git a/plugin_examples/modify_post_data.py b/plugin_examples/modify_post_data.py new file mode 100644 index 0000000000..0dfb63a71a --- /dev/null +++ b/plugin_examples/modify_post_data.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional + +from proxy.http.parser import HttpParser +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.methods import httpMethods +from proxy.common.utils import bytes_ + + +class ModifyPostDataPlugin(HttpProxyBasePlugin): + """Modify POST request body before sending to upstream server.""" + + MODIFIED_BODY = b'{"key": "modified"}' + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + if request.method == httpMethods.POST: + request.body = ModifyPostDataPlugin.MODIFIED_BODY + # Update Content-Length header only when request is NOT chunked + # encoded + if not request.is_chunked_encoded(): + request.add_header(b'Content-Length', + bytes_(len(request.body))) + # Enforce content-type json + if request.has_header(b'Content-Type'): + request.del_header(b'Content-Type') + request.add_header(b'Content-Type', b'application/json') + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_upstream_connection_close(self) -> None: + pass diff --git a/plugin_examples/redirect_to_custom_server.py b/plugin_examples/redirect_to_custom_server.py new file mode 100644 index 0000000000..f945fcd2c8 --- /dev/null +++ b/plugin_examples/redirect_to_custom_server.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from urllib import parse as urlparse +from typing import Optional + +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.parser import HttpParser +from proxy.http.methods import httpMethods + + +class RedirectToCustomServerPlugin(HttpProxyBasePlugin): + """Modifies client request to redirect all incoming requests to a fixed server address.""" + + UPSTREAM_SERVER = b'http://localhost:8899/' + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + # Redirect all non-https requests to inbuilt WebServer. + if request.method != httpMethods.CONNECT: + request.set_url(self.UPSTREAM_SERVER) + # Update Host header too, otherwise upstream can reject our request + if request.has_header(b'Host'): + request.del_header(b'Host') + request.add_header( + b'Host', urlparse.urlsplit( + self.UPSTREAM_SERVER).netloc) + return request + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_upstream_connection_close(self) -> None: + pass diff --git a/plugin_examples/shortlink.py b/plugin_examples/shortlink.py new file mode 100644 index 0000000000..a44d453093 --- /dev/null +++ b/plugin_examples/shortlink.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional + +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.parser import HttpParser +from proxy.http.codes import httpStatusCodes +from proxy.common.constants import DOT, SLASH +from proxy.common.utils import build_http_response + + +class ShortLinkPlugin(HttpProxyBasePlugin): + """Add support for short links in your favorite browsers / applications. + + Enable ShortLinkPlugin and speed up your daily browsing experience. + + Example: + * f/ for facebook.com + * g/ for google.com + * t/ for twitter.com + * y/ for youtube.com + * proxy/ for py internal web servers. + Customize map below for your taste and need. + + Paths are also preserved. E.g. t/imoracle will + resolve to http://twitter.com/imoracle. + """ + + SHORT_LINKS = { + b'a': b'amazon.com', + b'i': b'instagram.com', + b'l': b'linkedin.com', + b'f': b'facebook.com', + b'g': b'google.com', + b't': b'twitter.com', + b'w': b'web.whatsapp.com', + b'y': b'youtube.com', + b'proxy': b'localhost:8899', + } + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + if request.host and request.host != b'localhost' and DOT not in request.host: + # Avoid connecting to upstream + return None + return request + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + if request.host and request.host != b'localhost' and DOT not in request.host: + if request.host in self.SHORT_LINKS: + path = SLASH if not request.path else request.path + self.client.queue(build_http_response( + httpStatusCodes.SEE_OTHER, reason=b'See Other', + headers={ + b'Location': b'http://' + self.SHORT_LINKS[request.host] + path, + b'Content-Length': b'0', + b'Connection': b'close', + } + )) + else: + self.client.queue(build_http_response( + httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', + headers={ + b'Content-Length': b'0', + b'Connection': b'close', + } + )) + return None + return request + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_upstream_connection_close(self) -> None: + pass diff --git a/plugin_examples/web_server_route.py b/plugin_examples/web_server_route.py new file mode 100644 index 0000000000..76635624d1 --- /dev/null +++ b/plugin_examples/web_server_route.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging +from typing import List, Tuple + +from proxy.http.server import HttpWebServerBasePlugin, httpProtocolTypes +from proxy.http.websocket import WebsocketFrame +from proxy.http.parser import HttpParser +from proxy.http.codes import httpStatusCodes +from proxy.common.utils import build_http_response + +logger = logging.getLogger(__name__) + + +class WebServerPlugin(HttpWebServerBasePlugin): + """Demonstration of inbuilt web server routing via plugin.""" + + def routes(self) -> List[Tuple[int, bytes]]: + return [ + (httpProtocolTypes.HTTP, b'/http-route-example'), + (httpProtocolTypes.HTTPS, b'/https-route-example'), + (httpProtocolTypes.WEBSOCKET, b'/ws-route-example'), + ] + + def handle_request(self, request: HttpParser) -> None: + if request.path == b'/http-route-example': + self.client.queue(build_http_response( + httpStatusCodes.OK, body=b'HTTP route response')) + elif request.path == b'/https-route-example': + self.client.queue(build_http_response( + httpStatusCodes.OK, body=b'HTTPS route response')) + + def on_websocket_open(self) -> None: + logger.info('Websocket open') + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + logger.info(frame.data) + + def on_websocket_close(self) -> None: + logger.info('Websocket close') diff --git a/proxy.js b/proxy.js deleted file mode 100644 index 6a0a8f3716..0000000000 --- a/proxy.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -*/ -const path = require('path'); -const fs = require('fs'); -const ncp = require('ncp').ncp; -ncp.limit = 16; - -const publicFolderPath = path.join(__dirname, 'public'); -const destinationFolderPath = path.join(publicFolderPath, 'devtools'); - -const publicFolderExists = fs.existsSync(publicFolderPath); -if (!publicFolderExists) { - console.error(publicFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.'); - process.exit(1); -} - -const destinationFolderExists = fs.existsSync(destinationFolderPath); -if (!destinationFolderExists) { - console.error(destinationFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.'); - process.exit(1); -} - -const chromeDevTools = path.dirname(require.resolve('chrome-devtools-frontend/front_end/inspector.html')); - -console.log(chromeDevTools + ' ---> ' + destinationFolderPath); -ncp(chromeDevTools, destinationFolderPath, (err) => { - if (err) { - return console.error(err); - } - console.log('Copy successful!!!'); -}); diff --git a/proxy.py b/proxy.py deleted file mode 100755 index aaad662b2b..0000000000 --- a/proxy.py +++ /dev/null @@ -1,3243 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import argparse -import asyncio -import base64 -import contextlib -import errno -import functools -import hashlib -import importlib -import inspect -import io -import ipaddress -import json -import logging -import mimetypes -import multiprocessing -import os -import pathlib -import queue -import secrets -import selectors -import socket -import ssl -import struct -import subprocess -import sys -import threading -import time -from abc import ABC, abstractmethod -from multiprocessing import connection -from multiprocessing.reduction import send_handle, recv_handle -from types import TracebackType -from typing import Any, Dict, List, Tuple, Optional, Union, NamedTuple, Callable, Type, TypeVar -from typing import cast, Generator, TYPE_CHECKING -from urllib import parse as urlparse - -from typing_extensions import Protocol - -if os.name != 'nt': - import resource - -PROXY_PY_DIR = os.path.dirname(os.path.realpath(__file__)) -PROXY_PY_START_TIME = time.time() - -VERSION = (1, 2, 0) -__version__ = '.'.join(map(str, VERSION[0:3])) -__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' -__author__ = 'Abhinav Singh' -__author_email__ = 'mailsforabhinav@gmail.com' -__homepage__ = 'https://github.com/abhinavsingh/proxy.py' -__download_url__ = '%s/archive/master.zip' % __homepage__ -__license__ = 'BSD' - -# Defaults -DEFAULT_BACKLOG = 100 -DEFAULT_BASIC_AUTH = None -DEFAULT_BUFFER_SIZE = 1024 * 1024 -DEFAULT_CA_CERT_DIR = None -DEFAULT_CA_CERT_FILE = None -DEFAULT_CA_KEY_FILE = None -DEFAULT_CA_SIGNING_KEY_FILE = None -DEFAULT_CERT_FILE = None -DEFAULT_CLIENT_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE -DEFAULT_DEVTOOLS_WS_PATH = b'/devtools' -DEFAULT_DISABLE_HEADERS: List[bytes] = [] -DEFAULT_DISABLE_HTTP_PROXY = False -DEFAULT_ENABLE_DEVTOOLS = False -DEFAULT_ENABLE_STATIC_SERVER = False -DEFAULT_ENABLE_WEB_SERVER = False -DEFAULT_IPV4_HOSTNAME = ipaddress.IPv4Address('127.0.0.1') -DEFAULT_IPV6_HOSTNAME = ipaddress.IPv6Address('::1') -DEFAULT_KEY_FILE = None -DEFAULT_LOG_FILE = None -DEFAULT_LOG_FORMAT = '%(asctime)s - pid:%(process)d [%(levelname)-.1s] %(funcName)s:%(lineno)d - %(message)s' -DEFAULT_LOG_LEVEL = 'INFO' -DEFAULT_NUM_WORKERS = 0 -DEFAULT_OPEN_FILE_LIMIT = 1024 -DEFAULT_PAC_FILE = None -DEFAULT_PAC_FILE_URL_PATH = b'/' -DEFAULT_PID_FILE = None -DEFAULT_PLUGINS = '' -DEFAULT_PORT = 8899 -DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE -DEFAULT_STATIC_SERVER_DIR = os.path.join(PROXY_PY_DIR, 'public') -DEFAULT_THREADLESS = False -DEFAULT_TIMEOUT = 10 -DEFAULT_VERSION = False -UNDER_TEST = False # Set to True if under test - -logger = logging.getLogger(__name__) - - -def text_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: - """Utility to ensure text-like usability. - - If s is of type bytes or int, return s.decode(encoding, errors), - otherwise return s as it is.""" - if isinstance(s, int): - return str(s) - if isinstance(s, bytes): - return s.decode(encoding, errors) - return s - - -def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: - """Utility to ensure binary-like usability. - - If s is type str or int, return s.encode(encoding, errors), - otherwise return s as it is.""" - if isinstance(s, int): - s = str(s) - if isinstance(s, str): - return s.encode(encoding, errors) - return s - - -version = bytes_(__version__) -CRLF, COLON, WHITESPACE, COMMA, DOT, SLASH, HTTP_1_1 = b'\r\n', b':', b' ', b',', b'.', b'/', b'HTTP/1.1' -PROXY_AGENT_HEADER_KEY = b'Proxy-agent' -PROXY_AGENT_HEADER_VALUE = b'proxy.py v' + version -PROXY_AGENT_HEADER = PROXY_AGENT_HEADER_KEY + \ - COLON + WHITESPACE + PROXY_AGENT_HEADER_VALUE - -TcpConnectionTypes = NamedTuple('TcpConnectionTypes', [ - ('SERVER', int), - ('CLIENT', int), -]) -tcpConnectionTypes = TcpConnectionTypes(1, 2) - -ChunkParserStates = NamedTuple('ChunkParserStates', [ - ('WAITING_FOR_SIZE', int), - ('WAITING_FOR_DATA', int), - ('COMPLETE', int), -]) -chunkParserStates = ChunkParserStates(1, 2, 3) - -HttpStatusCodes = NamedTuple('HttpStatusCodes', [ - # 1xx - ('CONTINUE', int), - ('SWITCHING_PROTOCOLS', int), - # 2xx - ('OK', int), - # 3xx - ('MOVED_PERMANENTLY', int), - ('SEE_OTHER', int), - ('TEMPORARY_REDIRECT', int), - ('PERMANENT_REDIRECT', int), - # 4xx - ('BAD_REQUEST', int), - ('UNAUTHORIZED', int), - ('FORBIDDEN', int), - ('NOT_FOUND', int), - ('PROXY_AUTH_REQUIRED', int), - ('REQUEST_TIMEOUT', int), - ('I_AM_A_TEAPOT', int), - # 5xx - ('INTERNAL_SERVER_ERROR', int), - ('NOT_IMPLEMENTED', int), - ('BAD_GATEWAY', int), - ('GATEWAY_TIMEOUT', int), - ('NETWORK_READ_TIMEOUT_ERROR', int), - ('NETWORK_CONNECT_TIMEOUT_ERROR', int), -]) -httpStatusCodes = HttpStatusCodes( - 100, 101, - 200, - 301, 303, 307, 308, - 400, 401, 403, 404, 407, 408, 418, - 500, 501, 502, 504, 598, 599 -) - -HttpMethods = NamedTuple('HttpMethods', [ - ('GET', bytes), - ('HEAD', bytes), - ('POST', bytes), - ('PUT', bytes), - ('DELETE', bytes), - ('CONNECT', bytes), - ('OPTIONS', bytes), - ('TRACE', bytes), - ('PATCH', bytes), -]) -httpMethods = HttpMethods( - b'GET', - b'HEAD', - b'POST', - b'PUT', - b'DELETE', - b'CONNECT', - b'OPTIONS', - b'TRACE', - b'PATCH', -) - -HttpParserStates = NamedTuple('HttpParserStates', [ - ('INITIALIZED', int), - ('LINE_RCVD', int), - ('RCVING_HEADERS', int), - ('HEADERS_COMPLETE', int), - ('RCVING_BODY', int), - ('COMPLETE', int), -]) -httpParserStates = HttpParserStates(1, 2, 3, 4, 5, 6) - -HttpParserTypes = NamedTuple('HttpParserTypes', [ - ('REQUEST_PARSER', int), - ('RESPONSE_PARSER', int), -]) -httpParserTypes = HttpParserTypes(1, 2) - -HttpProtocolTypes = NamedTuple('HttpProtocolTypes', [ - ('HTTP', int), - ('HTTPS', int), - ('WEBSOCKET', int), -]) -httpProtocolTypes = HttpProtocolTypes(1, 2, 3) - -WebsocketOpcodes = NamedTuple('WebsocketOpcodes', [ - ('CONTINUATION_FRAME', int), - ('TEXT_FRAME', int), - ('BINARY_FRAME', int), - ('CONNECTION_CLOSE', int), - ('PING', int), - ('PONG', int), -]) -websocketOpcodes = WebsocketOpcodes(0x0, 0x1, 0x2, 0x8, 0x9, 0xA) - - -def build_http_request(method: bytes, url: bytes, - protocol_version: bytes = HTTP_1_1, - headers: Optional[Dict[bytes, bytes]] = None, - body: Optional[bytes] = None) -> bytes: - """Build and returns a HTTP request packet.""" - if headers is None: - headers = {} - return build_http_pkt( - [method, url, protocol_version], headers, body) - - -def build_http_response(status_code: int, - protocol_version: bytes = HTTP_1_1, - reason: Optional[bytes] = None, - headers: Optional[Dict[bytes, bytes]] = None, - body: Optional[bytes] = None) -> bytes: - """Build and returns a HTTP response packet.""" - line = [protocol_version, bytes_(status_code)] - if reason: - line.append(reason) - if headers is None: - headers = {} - has_content_length = False - has_transfer_encoding = False - for k in headers: - if k.lower() == b'content-length': - has_content_length = True - if k.lower() == b'transfer-encoding': - has_transfer_encoding = True - if body is not None and \ - not has_transfer_encoding and \ - not has_content_length: - headers[b'Content-Length'] = bytes_(len(body)) - return build_http_pkt(line, headers, body) - - -def build_http_header(k: bytes, v: bytes) -> bytes: - """Build and return a HTTP header line for use in raw packet.""" - return k + COLON + WHITESPACE + v - - -def build_http_pkt(line: List[bytes], - headers: Optional[Dict[bytes, bytes]] = None, - body: Optional[bytes] = None) -> bytes: - """Build and returns a HTTP request or response packet.""" - req = WHITESPACE.join(line) + CRLF - if headers is not None: - for k in headers: - req += build_http_header(k, headers[k]) + CRLF - req += CRLF - if body: - req += body - return req - - -def build_websocket_handshake_request( - key: bytes, - method: bytes = b'GET', - url: bytes = b'/') -> bytes: - """ - Build and returns a Websocket handshake request packet. - - :param key: Sec-WebSocket-Key header value. - :param method: HTTP method. - :param url: Websocket request path. - """ - return build_http_request( - method, url, - headers={ - b'Connection': b'upgrade', - b'Upgrade': b'websocket', - b'Sec-WebSocket-Key': key, - b'Sec-WebSocket-Version': b'13', - } - ) - - -def build_websocket_handshake_response(accept: bytes) -> bytes: - """ - Build and returns a Websocket handshake response packet. - - :param accept: Sec-WebSocket-Accept header value - """ - return build_http_response( - 101, reason=b'Switching Protocols', - headers={ - b'Upgrade': b'websocket', - b'Connection': b'Upgrade', - b'Sec-WebSocket-Accept': accept - } - ) - - -def find_http_line(raw: bytes) -> Tuple[Optional[bytes], bytes]: - """Find and returns first line ending in CRLF along with following buffer. - - If no ending CRLF is found, line is None.""" - pos = raw.find(CRLF) - if pos == -1: - return None, raw - line = raw[:pos] - rest = raw[pos + len(CRLF):] - return line, rest - - -def new_socket_connection(addr: Tuple[str, int]) -> socket.socket: - conn = None - try: - ip = ipaddress.ip_address(addr[0]) - if ip.version == 4: - conn = socket.socket( - socket.AF_INET, socket.SOCK_STREAM, 0) - conn.connect(addr) - else: - conn = socket.socket( - socket.AF_INET6, socket.SOCK_STREAM, 0) - conn.connect((addr[0], addr[1], 0, 0)) - except ValueError: - pass # does not appear to be an IPv4 or IPv6 address - - if conn is not None: - return conn - - # try to establish dual stack IPv4/IPv6 connection. - return socket.create_connection(addr) - - -class socket_connection(contextlib.ContextDecorator): - """Same as new_socket_connection but as a context manager and decorator.""" - - def __init__(self, addr: Tuple[str, int]): - self.addr: Tuple[str, int] = addr - self.conn: Optional[socket.socket] = None - super().__init__() - - def __enter__(self) -> socket.socket: - self.conn = new_socket_connection(self.addr) - return self.conn - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType]) -> bool: - if self.conn: - self.conn.close() - return False - - def __call__(self, func: Callable[..., Any]) -> Callable[[socket.socket], Any]: - @functools.wraps(func) - def decorated(*args: Any, **kwargs: Any) -> Any: - with self as conn: - return func(conn, *args, **kwargs) - return decorated - - -class _HasFileno(Protocol): - def fileno(self) -> int: - ... # pragma: no cover - - -class TcpConnectionUninitializedException(Exception): - pass - - -class TcpConnection(ABC): - """TCP server/client connection abstraction. - - Main motivation of this class is to provide a buffer management - when reading and writing into the socket. - - Implement the connection property abstract method to return - a socket connection object.""" - - def __init__(self, tag: int): - self.buffer: bytes = b'' - self.closed: bool = False - self.tag: str = 'server' if tag == tcpConnectionTypes.SERVER else 'client' - - @property - @abstractmethod - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - """Must return the socket connection to use in this class.""" - raise TcpConnectionUninitializedException() # pragma: no cover - - def send(self, data: bytes) -> int: - """Users must handle BrokenPipeError exceptions""" - return self.connection.send(data) - - def recv(self, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Optional[bytes]: - """Users must handle socket.error exceptions""" - data: bytes = self.connection.recv(buffer_size) - if len(data) == 0: - return None - logger.debug( - 'received %d bytes from %s' % - (len(data), self.tag)) - # logger.info(data) - return data - - def close(self) -> bool: - if not self.closed: - self.connection.close() - self.closed = True - return self.closed - - def buffer_size(self) -> int: - return len(self.buffer) - - def has_buffer(self) -> bool: - return self.buffer_size() > 0 - - def queue(self, data: bytes) -> int: - self.buffer += data - return len(data) - - def flush(self) -> int: - """Users must handle BrokenPipeError exceptions""" - if self.buffer_size() == 0: - return 0 - sent: int = self.send(self.buffer) - # logger.info(self.buffer[:sent]) - self.buffer = self.buffer[sent:] - logger.debug('flushed %d bytes to %s' % (sent, self.tag)) - return sent - - -class TcpServerConnection(TcpConnection): - """Establishes connection to upstream server.""" - - def __init__(self, host: str, port: int): - super().__init__(tcpConnectionTypes.SERVER) - self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None - self.addr: Tuple[str, int] = (host, int(port)) - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - if self._conn is None: - raise TcpConnectionUninitializedException() - return self._conn - - def connect(self) -> None: - if self._conn is not None: - return - self._conn = new_socket_connection(self.addr) - - -class TcpClientConnection(TcpConnection): - """An accepted client connection request.""" - - def __init__(self, - conn: Union[ssl.SSLSocket, socket.socket], - addr: Tuple[str, int]): - super().__init__(tcpConnectionTypes.CLIENT) - self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = conn - self.addr: Tuple[str, int] = addr - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - if self._conn is None: - raise TcpConnectionUninitializedException() - return self._conn - - -class ChunkParser: - """HTTP chunked encoding response parser.""" - - def __init__(self) -> None: - self.state = chunkParserStates.WAITING_FOR_SIZE - self.body: bytes = b'' # Parsed chunks - self.chunk: bytes = b'' # Partial chunk received - # Expected size of next following chunk - self.size: Optional[int] = None - - def parse(self, raw: bytes) -> bytes: - more = True if len(raw) > 0 else False - while more and self.state != chunkParserStates.COMPLETE: - more, raw = self.process(raw) - return raw - - def process(self, raw: bytes) -> Tuple[bool, bytes]: - if self.state == chunkParserStates.WAITING_FOR_SIZE: - # Consume prior chunk in buffer - # in case chunk size without CRLF was received - raw = self.chunk + raw - self.chunk = b'' - # Extract following chunk data size - line, raw = find_http_line(raw) - # CRLF not received or Blank line was received. - if line is None or line.strip() == b'': - self.chunk = raw - raw = b'' - else: - self.size = int(line, 16) - self.state = chunkParserStates.WAITING_FOR_DATA - elif self.state == chunkParserStates.WAITING_FOR_DATA: - assert self.size is not None - remaining = self.size - len(self.chunk) - self.chunk += raw[:remaining] - raw = raw[remaining:] - if len(self.chunk) == self.size: - raw = raw[len(CRLF):] - self.body += self.chunk - if self.size == 0: - self.state = chunkParserStates.COMPLETE - else: - self.state = chunkParserStates.WAITING_FOR_SIZE - self.chunk = b'' - self.size = None - return len(raw) > 0, raw - - @staticmethod - def to_chunks(raw: bytes, chunk_size: int = DEFAULT_BUFFER_SIZE) -> bytes: - chunks: List[bytes] = [] - for i in range(0, len(raw), chunk_size): - chunk = raw[i: i + chunk_size] - chunks.append(bytes_('{:x}'.format(len(chunk)))) - chunks.append(chunk) - chunks.append(bytes_('{:x}'.format(0))) - chunks.append(b'') - return CRLF.join(chunks) + CRLF - - -T = TypeVar('T', bound='HttpParser') - - -class HttpParser: - """HTTP request/response parser.""" - - def __init__(self, parser_type: int) -> None: - self.type: int = parser_type - self.state: int = httpParserStates.INITIALIZED - - # Raw bytes as passed to parse(raw) method and its total size - self.bytes: bytes = b'' - self.total_size: int = 0 - - # Buffer to hold unprocessed bytes - self.buffer: bytes = b'' - - self.headers: Dict[bytes, Tuple[bytes, bytes]] = dict() - self.body: Optional[bytes] = None - - self.method: Optional[bytes] = None - self.url: Optional[urlparse.SplitResultBytes] = None - self.code: Optional[bytes] = None - self.reason: Optional[bytes] = None - self.version: Optional[bytes] = None - - self.chunk_parser: Optional[ChunkParser] = None - - # This cleans up developer APIs as Python urlparse.urlsplit behaves differently - # for incoming proxy request and incoming web request. Web request is the one - # which is broken. - self.host: Optional[bytes] = None - self.port: Optional[int] = None - self.path: Optional[bytes] = None - - @classmethod - def request(cls: Type[T], raw: bytes) -> T: - parser = cls(httpParserTypes.REQUEST_PARSER) - parser.parse(raw) - return parser - - @classmethod - def response(cls: Type[T], raw: bytes) -> T: - parser = cls(httpParserTypes.RESPONSE_PARSER) - parser.parse(raw) - return parser - - def header(self, key: bytes) -> bytes: - if key.lower() not in self.headers: - raise KeyError('%s not found in headers', text_(key)) - return self.headers[key.lower()][1] - - def has_header(self, key: bytes) -> bool: - return key.lower() in self.headers - - def add_header(self, key: bytes, value: bytes) -> None: - self.headers[key.lower()] = (key, value) - - def add_headers(self, headers: List[Tuple[bytes, bytes]]) -> None: - for (key, value) in headers: - self.add_header(key, value) - - def del_header(self, header: bytes) -> None: - if header.lower() in self.headers: - del self.headers[header.lower()] - - def del_headers(self, headers: List[bytes]) -> None: - for key in headers: - self.del_header(key.lower()) - - def set_url(self, url: bytes) -> None: - self.url = urlparse.urlsplit(url) - self.set_line_attributes() - - def set_line_attributes(self) -> None: - if self.type == httpParserTypes.REQUEST_PARSER: - if self.method == httpMethods.CONNECT and self.url: - u = urlparse.urlsplit(b'//' + self.url.path) - self.host, self.port = u.hostname, u.port - elif self.url: - self.host, self.port = self.url.hostname, self.url.port \ - if self.url.port else 80 - else: - raise KeyError('Invalid request\n%s' % self.bytes) - self.path = self.build_url() - - def is_chunked_encoded(self) -> bool: - return b'transfer-encoding' in self.headers and \ - self.headers[b'transfer-encoding'][1].lower() == b'chunked' - - def parse(self, raw: bytes) -> None: - """Parses Http request out of raw bytes. - - Check HttpParser state after parse has successfully returned.""" - self.bytes += raw - self.total_size += len(raw) - - # Prepend past buffer - raw = self.buffer + raw - self.buffer = b'' - - more = True if len(raw) > 0 else False - while more and self.state != httpParserStates.COMPLETE: - if self.state in ( - httpParserStates.HEADERS_COMPLETE, - httpParserStates.RCVING_BODY): - if b'content-length' in self.headers: - self.state = httpParserStates.RCVING_BODY - if self.body is None: - self.body = b'' - total_size = int(self.header(b'content-length')) - received_size = len(self.body) - self.body += raw[:total_size - received_size] - if self.body and \ - len(self.body) == int(self.header(b'content-length')): - self.state = httpParserStates.COMPLETE - more, raw = len(raw) > 0, raw[total_size - received_size:] - elif self.is_chunked_encoded(): - if not self.chunk_parser: - self.chunk_parser = ChunkParser() - raw = self.chunk_parser.parse(raw) - if self.chunk_parser.state == chunkParserStates.COMPLETE: - self.body = self.chunk_parser.body - self.state = httpParserStates.COMPLETE - more = False - else: - more, raw = self.process(raw) - self.buffer = raw - - def process(self, raw: bytes) -> Tuple[bool, bytes]: - """Returns False when no CRLF could be found in received bytes.""" - line, raw = find_http_line(raw) - if line is None: - return False, raw - - if self.state == httpParserStates.INITIALIZED: - self.process_line(line) - self.state = httpParserStates.LINE_RCVD - elif self.state in (httpParserStates.LINE_RCVD, httpParserStates.RCVING_HEADERS): - if self.state == httpParserStates.LINE_RCVD: - # LINE_RCVD state is equivalent to RCVING_HEADERS - self.state = httpParserStates.RCVING_HEADERS - if line.strip() == b'': # Blank line received. - self.state = httpParserStates.HEADERS_COMPLETE - else: - self.process_header(line) - - # When connect request is received without a following host header - # See - # `TestHttpParser.test_connect_request_without_host_header_request_parse` - # for details - if self.state == httpParserStates.LINE_RCVD and \ - self.type == httpParserTypes.RESPONSE_PARSER and \ - raw == CRLF: - self.state = httpParserStates.COMPLETE - # When raw request has ended with \r\n\r\n and no more http headers are expected - # See `TestHttpParser.test_request_parse_without_content_length` and - # `TestHttpParser.test_response_parse_without_content_length` for details - elif self.state == httpParserStates.HEADERS_COMPLETE and \ - self.type == httpParserTypes.REQUEST_PARSER and \ - self.method != httpMethods.POST and \ - self.bytes.endswith(CRLF * 2): - self.state = httpParserStates.COMPLETE - elif self.state == httpParserStates.HEADERS_COMPLETE and \ - self.type == httpParserTypes.REQUEST_PARSER and \ - self.method == httpMethods.POST and \ - not self.is_chunked_encoded() and \ - (b'content-length' not in self.headers or - (b'content-length' in self.headers and - int(self.headers[b'content-length'][1]) == 0)) and \ - self.bytes.endswith(CRLF * 2): - self.state = httpParserStates.COMPLETE - - return len(raw) > 0, raw - - def process_line(self, raw: bytes) -> None: - line = raw.split(WHITESPACE) - if self.type == httpParserTypes.REQUEST_PARSER: - self.method = line[0].upper() - self.set_url(line[1]) - self.version = line[2] - else: - self.version = line[0] - self.code = line[1] - self.reason = WHITESPACE.join(line[2:]) - - def process_header(self, raw: bytes) -> None: - parts = raw.split(COLON) - key = parts[0].strip() - value = COLON.join(parts[1:]).strip() - self.add_headers([(key, value)]) - - def build_url(self) -> bytes: - if not self.url: - return b'/None' - url = self.url.path - if url == b'': - url = b'/' - if not self.url.query == b'': - url += b'?' + self.url.query - if not self.url.fragment == b'': - url += b'#' + self.url.fragment - return url - - def build(self, disable_headers: Optional[List[bytes]] = None) -> bytes: - assert self.method and self.version and self.path - if disable_headers is None: - disable_headers = DEFAULT_DISABLE_HEADERS - body: Optional[bytes] = ChunkParser.to_chunks(self.body) \ - if self.is_chunked_encoded() and self.body else \ - self.body - return build_http_request( - self.method, self.path, self.version, - headers={} if not self.headers else {self.headers[k][0]: self.headers[k][1] for k in self.headers if - k.lower() not in disable_headers}, - body=body - ) - - def has_upstream_server(self) -> bool: - """Host field SHOULD be None for incoming local WebServer requests.""" - return True if self.host is not None else False - - def is_http_1_1_keep_alive(self) -> bool: - return self.version == HTTP_1_1 and \ - (not self.has_header(b'Connection') or - self.header(b'Connection').lower() == b'keep-alive') - - -class AcceptorPool: - """AcceptorPool. - - Pre-spawns worker processes to utilize all cores available on the system. Server socket connection is - dispatched over a pipe to workers. Each worker accepts incoming client request and spawns a - separate thread to handle the client request. - """ - - def __init__(self, - hostname: Union[ipaddress.IPv4Address, - ipaddress.IPv6Address], - port: int, backlog: int, num_workers: int, - threadless: bool, - work_klass: type, - **kwargs: Any) -> None: - self.threadless = threadless - self.running: bool = False - - self.hostname: Union[ipaddress.IPv4Address, - ipaddress.IPv6Address] = hostname - self.port: int = port - self.family: socket.AddressFamily = socket.AF_INET6 if hostname.version == 6 else socket.AF_INET - self.backlog: int = backlog - self.socket: Optional[socket.socket] = None - - self.num_acceptors = num_workers - self.acceptors: List[Acceptor] = [] - self.work_queues: List[connection.Connection] = [] - - self.work_klass = work_klass - self.kwargs = kwargs - - def listen(self) -> None: - self.socket = socket.socket(self.family, socket.SOCK_STREAM) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.socket.bind((str(self.hostname), self.port)) - self.socket.listen(self.backlog) - self.socket.setblocking(False) - logger.info('Listening on %s:%d' % (self.hostname, self.port)) - - def start_workers(self) -> None: - """Start worker processes.""" - for _ in range(self.num_acceptors): - work_queue = multiprocessing.Pipe() - acceptor = Acceptor( - self.family, - self.threadless, - work_queue[1], - self.work_klass, - **self.kwargs - ) - # acceptor.daemon = True - acceptor.start() - self.acceptors.append(acceptor) - self.work_queues.append(work_queue[0]) - logger.info('Started %d workers' % self.num_acceptors) - - def shutdown(self) -> None: - logger.info('Shutting down %d workers' % self.num_acceptors) - for acceptor in self.acceptors: - acceptor.join() - for work_queue in self.work_queues: - work_queue.close() - - def setup(self) -> None: - """Listen on port, setup workers and pass server socket to workers.""" - self.running = True - self.listen() - self.start_workers() - - # Send server socket to all acceptor processes. - assert self.socket is not None - for index in range(self.num_acceptors): - send_handle( - self.work_queues[index], - self.socket.fileno(), - self.acceptors[index].pid - ) - self.socket.close() - - -class ThreadlessWork(ABC): - """Implement ThreadlessWork to hook into the event loop provided by Threadless process.""" - - @abstractmethod - def initialize(self) -> None: - pass # pragma: no cover - - @abstractmethod - def is_inactive(self) -> bool: - return False # pragma: no cover - - @abstractmethod - def get_events(self) -> Dict[socket.socket, int]: - return {} # pragma: no cover - - @abstractmethod - def handle_events(self, - readables: List[Union[int, _HasFileno]], - writables: List[Union[int, _HasFileno]]) -> bool: - """Return True to shutdown work.""" - return False # pragma: no cover - - @abstractmethod - def shutdown(self) -> None: - """Must close any opened resources.""" - pass # pragma: no cover - - -class Threadless(multiprocessing.Process): - """Threadless provides an event loop. Use it by implementing Threadless class. - - When --threadless option is enabled, each Acceptor process also - spawns one Threadless process. And instead of spawning new thread - for each accepted client connection, Acceptor process sends - accepted client connection to Threadless process over a pipe. - - ProtocolHandler implements ThreadlessWork class and hooks into the - event loop provided by Threadless. - """ - - def __init__( - self, - client_queue: connection.Connection, - work_klass: type, - **kwargs: Any) -> None: - super().__init__() - self.client_queue = client_queue - self.work_klass = work_klass - self.kwargs = kwargs - - self.works: Dict[int, ThreadlessWork] = {} - self.selector: Optional[selectors.DefaultSelector] = None - self.loop: Optional[asyncio.AbstractEventLoop] = None - - @contextlib.contextmanager - def selected_events(self) -> Generator[Tuple[List[Union[int, _HasFileno]], - List[Union[int, _HasFileno]]], - None, None]: - events: Dict[socket.socket, int] = {} - for work in self.works.values(): - events.update(work.get_events()) - assert self.selector is not None - for fd in events: - self.selector.register(fd, events[fd]) - ev = self.selector.select(timeout=1) - readables = [] - writables = [] - for key, mask in ev: - if mask & selectors.EVENT_READ: - readables.append(key.fileobj) - if mask & selectors.EVENT_WRITE: - writables.append(key.fileobj) - yield (readables, writables) - for fd in events.keys(): - self.selector.unregister(fd) - - async def handle_events( - self, fileno: int, - readables: List[Union[int, _HasFileno]], - writables: List[Union[int, _HasFileno]]) -> bool: - return self.works[fileno].handle_events(readables, writables) - - # TODO: Use correct future typing annotations - async def wait_for_tasks( - self, tasks: Dict[int, Any]) -> None: - for work_id in tasks: - # TODO: Resolving one handle_events here can block resolution of other tasks - try: - teardown = await asyncio.wait_for(tasks[work_id], DEFAULT_TIMEOUT) - if teardown: - self.cleanup(work_id) - except asyncio.TimeoutError: - self.cleanup(work_id) - - def accept_client(self) -> None: - addr = self.client_queue.recv() - fileno = recv_handle(self.client_queue) - self.works[fileno] = self.work_klass( - fileno=fileno, - addr=addr, - **self.kwargs) - try: - self.works[fileno].initialize() - os.close(fileno) - except ssl.SSLError as e: - logger.exception('ssl.SSLError', exc_info=e) - self.cleanup(fileno) - - def cleanup_inactive(self) -> None: - inactive_works: List[int] = [] - for work_id in self.works: - if self.works[work_id].is_inactive(): - inactive_works.append(work_id) - for work_id in inactive_works: - self.cleanup(work_id) - - def cleanup(self, work_id: int) -> None: - # TODO: ProtocolHandler.shutdown can call flush which may block - self.works[work_id].shutdown() - del self.works[work_id] - - def run_once(self) -> None: - assert self.loop is not None - readables: List[Union[int, _HasFileno]] = [] - writables: List[Union[int, _HasFileno]] = [] - with self.selected_events() as (readables, writables): - if len(readables) == 0 and len(writables) == 0: - # Remove and shutdown inactive connections - self.cleanup_inactive() - return - # Note that selector from now on is idle, - # until all the logic below completes. - # - # Invoke Threadless.handle_events - # TODO: Only send readable / writables that client originally registered. - tasks = {} - for fileno in self.works: - tasks[fileno] = self.loop.create_task( - self.handle_events(fileno, readables, writables)) - # Accepted client connection from Acceptor - if self.client_queue in readables: - self.accept_client() - # Wait for Threadless.handle_events to complete - self.loop.run_until_complete(self.wait_for_tasks(tasks)) - # Remove and shutdown inactive connections - self.cleanup_inactive() - - def run(self) -> None: - try: - self.selector = selectors.DefaultSelector() - self.selector.register(self.client_queue, selectors.EVENT_READ) - self.loop = asyncio.get_event_loop() - while True: - self.run_once() - except KeyboardInterrupt: - pass - finally: - assert self.selector is not None - self.selector.unregister(self.client_queue) - self.client_queue.close() - assert self.loop is not None - self.loop.close() - - -class Acceptor(multiprocessing.Process): - """Socket client acceptor. - - Accepts client connection over received server socket handle and - starts a new work thread. - """ - - lock = multiprocessing.Lock() - - def __init__( - self, - family: socket.AddressFamily, - threadless: bool, - work_queue: connection.Connection, - work_klass: type, - **kwargs: Any) -> None: - super().__init__() - self.family: socket.AddressFamily = family - self.threadless: bool = threadless - self.work_queue: connection.Connection = work_queue - self.work_klass = work_klass - self.kwargs = kwargs - - self.running = False - self.selector: Optional[selectors.DefaultSelector] = None - self.sock: Optional[socket.socket] = None - self.threadless_process: Optional[multiprocessing.Process] = None - self.threadless_client_queue: Optional[connection.Connection] = None - - def start_threadless_process(self) -> None: - if not self.threadless: - return - pipe = multiprocessing.Pipe() - self.threadless_client_queue = pipe[0] - self.threadless_process = Threadless( - pipe[1], self.work_klass, **self.kwargs - ) - # self.threadless_process.daemon = True - self.threadless_process.start() - - def shutdown_threadless_process(self) -> None: - if not self.threadless: - return - assert self.threadless_process and self.threadless_client_queue - self.threadless_process.join() - self.threadless_client_queue.close() - - def run_once(self) -> None: - assert self.selector - with self.lock: - events = self.selector.select(timeout=1) - if len(events) == 0: - return - try: - assert self.sock - conn, addr = self.sock.accept() - except BlockingIOError: - return - if self.threadless and \ - self.threadless_client_queue and \ - self.threadless_process: - self.threadless_client_queue.send(addr) - send_handle( - self.threadless_client_queue, - conn.fileno(), - self.threadless_process.pid - ) - conn.close() - else: - # Starting a new thread per client request simply means - # we need 1 million threads to handle a million concurrent - # connections. Since most of the client requests are short - # lived (even with keep-alive), starting threads is excessive. - work = self.work_klass( - fileno=conn.fileno(), - addr=addr, - **self.kwargs) - # work.setDaemon(True) - work.start() - - def run(self) -> None: - self.running = True - self.selector = selectors.DefaultSelector() - fileno = recv_handle(self.work_queue) - self.sock = socket.fromfd( - fileno, - family=self.family, - type=socket.SOCK_STREAM - ) - try: - self.selector.register(self.sock, selectors.EVENT_READ) - self.start_threadless_process() - while self.running: - self.run_once() - except KeyboardInterrupt: - pass - finally: - self.selector.unregister(self.sock) - self.shutdown_threadless_process() - self.sock.close() - self.work_queue.close() - self.running = False - - -class ProtocolException(Exception): - """Top level ProtocolException exception class. - - All exceptions raised during execution of Http request lifecycle MUST - inherit ProtocolException base class. Implement response() method - to optionally return custom response to client.""" - - def response(self, request: HttpParser) -> Optional[bytes]: - return None # pragma: no cover - - -class HttpRequestRejected(ProtocolException): - """Generic exception that can be used to reject the client requests. - - Connections can either be dropped/closed or optionally an - HTTP status code can be returned.""" - - def __init__(self, - status_code: Optional[int] = None, - reason: Optional[bytes] = None, - body: Optional[bytes] = None): - self.status_code: Optional[int] = status_code - self.reason: Optional[bytes] = reason - self.body: Optional[bytes] = body - - def response(self, _request: HttpParser) -> Optional[bytes]: - pkt = [] - if self.status_code is not None: - line = HTTP_1_1 + WHITESPACE + bytes_(self.status_code) - if self.reason: - line += WHITESPACE + self.reason - pkt.append(line) - pkt.append(PROXY_AGENT_HEADER) - if self.body: - pkt.append(b'Content-Length: ' + bytes_(len(self.body))) - pkt.append(CRLF) - pkt.append(self.body) - else: - if len(pkt) > 0: - pkt.append(CRLF) - return CRLF.join(pkt) if len(pkt) > 0 else None - - -class ProxyConnectionFailed(ProtocolException): - """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server.""" - - RESPONSE_PKT = build_http_response( - httpStatusCodes.BAD_GATEWAY, - reason=b'Bad Gateway', - headers={ - PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close' - }, - body=b'Bad Gateway' - ) - - def __init__(self, host: str, port: int, reason: str): - self.host: str = host - self.port: int = port - self.reason: str = reason - - def response(self, _request: HttpParser) -> bytes: - return self.RESPONSE_PKT - - -class ProxyAuthenticationFailed(ProtocolException): - """Exception raised when Http Proxy auth is enabled and - incoming request doesn't present necessary credentials.""" - - RESPONSE_PKT = build_http_response( - httpStatusCodes.PROXY_AUTH_REQUIRED, - reason=b'Proxy Authentication Required', - headers={ - PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, - b'Proxy-Authenticate': b'Basic', - b'Connection': b'close', - }, - body=b'Proxy Authentication Required') - - def response(self, _request: HttpParser) -> bytes: - return self.RESPONSE_PKT - - -if TYPE_CHECKING: - DevtoolsEventQueueType = queue.Queue[Dict[str, Any]] # pragma: no cover -else: - DevtoolsEventQueueType = queue.Queue - - -class ProtocolConfig: - """Holds various configuration values applicable to ProtocolHandler. - - This config class helps us avoid passing around bunch of key/value pairs across methods. - """ - - ROOT_DATA_DIR_NAME = '.proxy.py' - GENERATED_CERTS_DIR_NAME = 'certificates' - - def __init__( - self, - auth_code: Optional[bytes] = DEFAULT_BASIC_AUTH, - server_recvbuf_size: int = DEFAULT_SERVER_RECVBUF_SIZE, - client_recvbuf_size: int = DEFAULT_CLIENT_RECVBUF_SIZE, - pac_file: Optional[str] = DEFAULT_PAC_FILE, - pac_file_url_path: Optional[bytes] = DEFAULT_PAC_FILE_URL_PATH, - plugins: Optional[Dict[bytes, List[type]]] = None, - disable_headers: Optional[List[bytes]] = None, - certfile: Optional[str] = None, - keyfile: Optional[str] = None, - ca_cert_dir: Optional[str] = None, - ca_key_file: Optional[str] = None, - ca_cert_file: Optional[str] = None, - ca_signing_key_file: Optional[str] = None, - num_workers: int = 0, - hostname: Union[ipaddress.IPv4Address, - ipaddress.IPv6Address] = DEFAULT_IPV6_HOSTNAME, - port: int = DEFAULT_PORT, - backlog: int = DEFAULT_BACKLOG, - static_server_dir: str = DEFAULT_STATIC_SERVER_DIR, - enable_static_server: bool = DEFAULT_ENABLE_STATIC_SERVER, - devtools_event_queue: Optional[DevtoolsEventQueueType] = None, - devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH, - timeout: int = DEFAULT_TIMEOUT, - threadless: bool = DEFAULT_THREADLESS) -> None: - self.threadless = threadless - self.timeout = timeout - self.auth_code = auth_code - self.server_recvbuf_size = server_recvbuf_size - self.client_recvbuf_size = client_recvbuf_size - self.pac_file = pac_file - self.pac_file_url_path = pac_file_url_path - if plugins is None: - plugins = {} - self.plugins: Dict[bytes, List[type]] = plugins - if disable_headers is None: - disable_headers = DEFAULT_DISABLE_HEADERS - self.disable_headers = disable_headers - self.certfile: Optional[str] = certfile - self.keyfile: Optional[str] = keyfile - self.ca_key_file: Optional[str] = ca_key_file - self.ca_cert_file: Optional[str] = ca_cert_file - self.ca_signing_key_file: Optional[str] = ca_signing_key_file - self.num_workers: int = num_workers - self.hostname: Union[ipaddress.IPv4Address, - ipaddress.IPv6Address] = hostname - self.port: int = port - self.backlog: int = backlog - - self.enable_static_server: bool = enable_static_server - self.static_server_dir: str = static_server_dir - self.devtools_event_queue: Optional[DevtoolsEventQueueType] = devtools_event_queue - self.devtools_ws_path: bytes = devtools_ws_path - - self.proxy_py_data_dir = os.path.join( - str(pathlib.Path.home()), self.ROOT_DATA_DIR_NAME) - os.makedirs(self.proxy_py_data_dir, exist_ok=True) - - self.ca_cert_dir: Optional[str] = ca_cert_dir - if self.ca_cert_dir is None: - self.ca_cert_dir = os.path.join( - self.proxy_py_data_dir, self.GENERATED_CERTS_DIR_NAME) - os.makedirs(self.ca_cert_dir, exist_ok=True) - - def tls_interception_enabled(self) -> bool: - return self.ca_key_file is not None and \ - self.ca_cert_dir is not None and \ - self.ca_signing_key_file is not None and \ - self.ca_cert_file is not None - - def encryption_enabled(self) -> bool: - return self.keyfile is not None and \ - self.certfile is not None - - -class ProtocolHandlerPlugin(ABC): - """Base ProtocolHandler Plugin class. - - NOTE: This is an internal plugin and in most cases only useful for core contributors. - If you are looking for proxy server plugins see ``. - - Implements various lifecycle events for an accepted client connection. - Following events are of interest: - - 1. Client Connection Accepted - A new plugin instance is created per accepted client connection. - Add your logic within __init__ constructor for any per connection setup. - 2. Client Request Chunk Received - on_client_data is called for every chunk of data sent by the client. - 3. Client Request Complete - on_request_complete is called once client request has completed. - 4. Server Response Chunk Received - on_response_chunk is called for every chunk received from the server. - 5. Client Connection Closed - Add your logic within `on_client_connection_close` for any per connection teardown. - """ - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection, - request: HttpParser): - self.config: ProtocolConfig = config - self.client: TcpClientConnection = client - self.request: HttpParser = request - super().__init__() - - def name(self) -> str: - """A unique name for your plugin. - - Defaults to name of the class. This helps plugin developers to directly - access a specific plugin by its name.""" - return self.__class__.__name__ - - @abstractmethod - def get_descriptors( - self) -> Tuple[List[socket.socket], List[socket.socket]]: - return [], [] # pragma: no cover - - @abstractmethod - def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: - pass # pragma: no cover - - @abstractmethod - def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: - pass # pragma: no cover - - @abstractmethod - def on_client_data(self, raw: bytes) -> Optional[bytes]: - return raw # pragma: no cover - - @abstractmethod - def on_request_complete(self) -> Union[socket.socket, bool]: - """Called right after client request parser has reached COMPLETE state.""" - pass # pragma: no cover - - @abstractmethod - def on_response_chunk(self, chunk: bytes) -> bytes: - """Handle data chunks as received from the server. - - Return optionally modified chunk to return back to client.""" - return chunk # pragma: no cover - - @abstractmethod - def on_client_connection_close(self) -> None: - pass # pragma: no cover - - -class HttpProxyBasePlugin(ABC): - """Base HttpProxyPlugin Plugin class. - - Implement various lifecycle event methods to customize behavior.""" - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection): - self.config = config # pragma: no cover - self.client = client # pragma: no cover - - def name(self) -> str: - """A unique name for your plugin. - - Defaults to name of the class. This helps plugin developers to directly - access a specific plugin by its name.""" - return self.__class__.__name__ # pragma: no cover - - @abstractmethod - def before_upstream_connection(self, request: HttpParser) -> Optional[HttpParser]: - """Handler called just before Proxy upstream connection is established. - - Return optionally modified request object. - Raise HttpRequestRejected or ProtocolException directly to drop the connection.""" - return request # pragma: no cover - - @abstractmethod - def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: - """Handler called before dispatching client request to upstream. - - Note: For pipelined (keep-alive) connections, this handler can be - called multiple times, for each request sent to upstream. - - Note: If TLS interception is enabled, this handler can - be called multiple times if client exchanges multiple - requests over same SSL session. - - Return optionally modified request object to dispatch to upstream. - Return None to drop the request data, e.g. in case a response has already been queued. - Raise HttpRequestRejected or ProtocolException directly to - teardown the connection with client. - """ - return request # pragma: no cover - - @abstractmethod - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - """Handler called right after receiving raw response from upstream server. - - For HTTPS connections, chunk will be encrypted unless - TLS interception is also enabled.""" - return chunk # pragma: no cover - - @abstractmethod - def on_upstream_connection_close(self) -> None: - """Handler called right after upstream connection has been closed.""" - pass # pragma: no cover - - -class HttpProxyPlugin(ProtocolHandlerPlugin): - """ProtocolHandler plugin which implements HttpProxy specifications.""" - - PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = build_http_response( - httpStatusCodes.OK, - reason=b'Connection established' - ) - - # Used to synchronize with other HttpProxyPlugin instances while - # generating certificates - lock = threading.Lock() - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection, - request: HttpParser): - super().__init__(config, client, request) - self.start_time: float = time.time() - self.server: Optional[TcpServerConnection] = None - self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) - self.pipeline_request: Optional[HttpParser] = None - self.pipeline_response: Optional[HttpParser] = None - - self.plugins: Dict[str, HttpProxyBasePlugin] = {} - if b'HttpProxyBasePlugin' in self.config.plugins: - for klass in self.config.plugins[b'HttpProxyBasePlugin']: - instance = klass(self.config, self.client) - self.plugins[instance.name()] = instance - - def get_descriptors( - self) -> Tuple[List[socket.socket], List[socket.socket]]: - if not self.request.has_upstream_server(): - return [], [] - - r: List[socket.socket] = [] - w: List[socket.socket] = [] - if self.server and not self.server.closed and self.server.connection: - r.append(self.server.connection) - if self.server and not self.server.closed and \ - self.server.has_buffer() and self.server.connection: - w.append(self.server.connection) - return r, w - - def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: - if self.request.has_upstream_server() and \ - self.server and not self.server.closed and \ - self.server.has_buffer() and \ - self.server.connection in w: - logger.debug('Server is write ready, flushing buffer') - try: - self.server.flush() - except OSError: - logger.error('OSError when flushing buffer to server') - return True - except BrokenPipeError: - logger.error( - 'BrokenPipeError when flushing buffer for server') - return True - return False - - def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: - if self.request.has_upstream_server( - ) and self.server and not self.server.closed and self.server.connection in r: - logger.debug('Server is ready for reads, reading...') - raw: Optional[bytes] = None - - try: - raw = self.server.recv(self.config.server_recvbuf_size) - except ssl.SSLWantReadError: # Try again later - # logger.warning('SSLWantReadError encountered while reading from server, will retry ...') - return False - except socket.error as e: - if e.errno == errno.ECONNRESET: - logger.warning('Connection reset by upstream: %r' % e) - else: - logger.exception( - 'Exception while receiving from %s connection %r with reason %r' % - (self.server.tag, self.server.connection, e)) - return True - - if not raw: - logger.debug('Server closed connection, tearing down...') - return True - - for plugin in self.plugins.values(): - raw = plugin.handle_upstream_chunk(raw) - - # parse incoming response packet - # only for non-https requests and when - # tls interception is enabled - if self.request.method != httpMethods.CONNECT: - # See https://github.com/abhinavsingh/proxy.py/issues/127 for why - # currently response parsing is disabled when TLS interception is enabled. - # - # or self.config.tls_interception_enabled(): - if self.response.state == httpParserStates.COMPLETE: - if self.pipeline_response is None: - self.pipeline_response = HttpParser(httpParserTypes.RESPONSE_PARSER) - self.pipeline_response.parse(raw) - if self.pipeline_response.state == httpParserStates.COMPLETE: - self.pipeline_response = None - else: - self.response.parse(raw) - else: - self.response.total_size += len(raw) - # queue raw data for client - self.client.queue(raw) - return False - - def access_log(self) -> None: - server_host, server_port = self.server.addr if self.server else ( - None, None) - connection_time_ms = (time.time() - self.start_time) * 1000 - if self.request.method == b'CONNECT': - logger.info( - '%s:%s - %s %s:%s - %s bytes - %.2f ms' % - (self.client.addr[0], - self.client.addr[1], - text_(self.request.method), - text_(server_host), - text_(server_port), - self.response.total_size, - connection_time_ms)) - elif self.request.method: - logger.info( - '%s:%s - %s %s:%s%s - %s %s - %s bytes - %.2f ms' % - (self.client.addr[0], self.client.addr[1], - text_(self.request.method), - text_(server_host), server_port, - text_(self.request.path), - text_(self.response.code), - text_(self.response.reason), - self.response.total_size, - connection_time_ms)) - - def on_client_connection_close(self) -> None: - if not self.request.has_upstream_server(): - return - - self.access_log() - - # If server was never initialized, return - if self.server is None: - return - - # Note that, server instance was initialized - # but not necessarily the connection object exists. - # Invoke plugin.on_upstream_connection_close - for plugin in self.plugins.values(): - plugin.on_upstream_connection_close() - - try: - try: - self.server.connection.shutdown(socket.SHUT_WR) - except OSError: - pass - finally: - # TODO: Unwrap if wrapped before close? - self.server.connection.close() - except TcpConnectionUninitializedException: - pass - finally: - logger.debug( - 'Closed server connection with pending server buffer size %d bytes' % - self.server.buffer_size()) - - def on_response_chunk(self, chunk: bytes) -> bytes: - # TODO: Allow to output multiple access_log lines - # for each request over a pipelined HTTP connection (not for HTTPS). - # However, this must also be accompanied by resetting both request - # and response objects. - # - # if not self.request.method == httpMethods.CONNECT and \ - # self.response.state == httpParserStates.COMPLETE: - # self.access_log() - return chunk - - def on_client_data(self, raw: bytes) -> Optional[bytes]: - if not self.request.has_upstream_server(): - return raw - - if self.server and not self.server.closed: - if self.request.state == httpParserStates.COMPLETE and ( - self.request.method != httpMethods.CONNECT or - self.config.tls_interception_enabled()): - if self.pipeline_request is None: - self.pipeline_request = HttpParser(httpParserTypes.REQUEST_PARSER) - self.pipeline_request.parse(raw) - if self.pipeline_request.state == httpParserStates.COMPLETE: - for plugin in self.plugins.values(): - assert self.pipeline_request is not None - r = plugin.handle_client_request(self.pipeline_request) - if r is None: - return None - self.pipeline_request = r - assert self.pipeline_request is not None - self.server.queue(self.pipeline_request.build()) - self.pipeline_request = None - else: - self.server.queue(raw) - return None - else: - return raw - - @staticmethod - def generated_cert_file_path(ca_cert_dir: str, host: str) -> str: - return os.path.join(ca_cert_dir, '%s.pem' % host) - - def generate_upstream_certificate(self, _certificate: Optional[Dict[str, Any]]) -> str: - if not (self.config.ca_cert_dir and self.config.ca_signing_key_file and - self.config.ca_cert_file and self.config.ca_key_file): - raise ProtocolException( - f'For certificate generation all the following flags are mandatory: ' - f'--ca-cert-file:{ self.config.ca_cert_file }, ' - f'--ca-key-file:{ self.config.ca_key_file }, ' - f'--ca-signing-key-file:{ self.config.ca_signing_key_file }') - cert_file_path = HttpProxyPlugin.generated_cert_file_path( - self.config.ca_cert_dir, text_(self.request.host)) - with self.lock: - if not os.path.isfile(cert_file_path): - logger.debug('Generating certificates %s', cert_file_path) - # TODO: Parse subject from certificate - # Currently we only set CN= field for generated certificates. - gen_cert = subprocess.Popen( - ['openssl', 'req', '-new', '-key', self.config.ca_signing_key_file, '-subj', - f'/C=/ST=/L=/O=/OU=/CN={ text_(self.request.host) }'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - sign_cert = subprocess.Popen( - ['openssl', 'x509', '-req', '-days', '365', '-CA', self.config.ca_cert_file, '-CAkey', - self.config.ca_key_file, '-set_serial', str(int(time.time())), '-out', cert_file_path], - stdin=gen_cert.stdout, - stderr=subprocess.PIPE) - # TODO: Ensure sign_cert success. - sign_cert.communicate(timeout=10) - return cert_file_path - - def wrap_server(self) -> None: - assert self.server is not None - assert isinstance(self.server.connection, socket.socket) - ctx = ssl.create_default_context( - ssl.Purpose.SERVER_AUTH) - ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 - self.server.connection.setblocking(True) - self.server._conn = ctx.wrap_socket( - self.server.connection, - server_hostname=text_(self.request.host)) - self.server.connection.setblocking(False) - - def wrap_client(self) -> None: - assert self.server is not None - assert isinstance(self.server.connection, ssl.SSLSocket) - generated_cert = self.generate_upstream_certificate( - cast(Dict[str, Any], self.server.connection.getpeercert())) - self.client.connection.setblocking(True) - self.client.flush() - self.client._conn = ssl.wrap_socket( - self.client.connection, - server_side=True, - keyfile=self.config.ca_signing_key_file, - certfile=generated_cert) - self.client.connection.setblocking(False) - logger.debug( - 'TLS interception using %s', generated_cert) - - def on_request_complete(self) -> Union[socket.socket, bool]: - if not self.request.has_upstream_server(): - return False - - self.authenticate() - - # Note: can raise HttpRequestRejected exception - # Invoke plugin.before_upstream_connection - do_connect = True - for plugin in self.plugins.values(): - r = plugin.before_upstream_connection(self.request) - if r is None: - do_connect = False - break - self.request = r - - if do_connect: - self.connect_upstream() - - for plugin in self.plugins.values(): - assert self.request is not None - r = plugin.handle_client_request(self.request) - if r is not None: - self.request = r - else: - return False - - if self.request.method == httpMethods.CONNECT: - self.client.queue( - HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) - # If interception is enabled - if self.config.tls_interception_enabled(): - # Perform SSL/TLS handshake with upstream - self.wrap_server() - # Generate certificate and perform handshake with client - try: - # wrap_client also flushes client data before wrapping - # sending to client can raise, handle expected exceptions - self.wrap_client() - except OSError: - logger.error('OSError when wrapping client') - return True - except BrokenPipeError: - logger.error( - 'BrokenPipeError when wrapping client') - return True - # Update all plugin connection reference - for plugin in self.plugins.values(): - plugin.client._conn = self.client.connection - return self.client.connection - elif self.server: - # - proxy-connection header is a mistake, it doesn't seem to be - # officially documented in any specification, drop it. - # - proxy-authorization is of no use for upstream, remove it. - self.request.del_headers( - [b'proxy-authorization', b'proxy-connection']) - # - For HTTP/1.0, connection header defaults to close - # - For HTTP/1.1, connection header defaults to keep-alive - # Respect headers sent by client instead of manipulating - # Connection or Keep-Alive header. However, note that per - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection - # connection headers are meant for communication between client and - # first intercepting proxy. - self.request.add_headers([(b'Via', b'1.1 %s' % PROXY_AGENT_HEADER_VALUE)]) - # Disable args.disable_headers before dispatching to upstream - self.server.queue( - self.request.build( - disable_headers=self.config.disable_headers)) - return False - - def authenticate(self) -> None: - if self.config.auth_code: - if b'proxy-authorization' not in self.request.headers or \ - self.request.headers[b'proxy-authorization'][1] != self.config.auth_code: - raise ProxyAuthenticationFailed() - - def connect_upstream(self) -> None: - host, port = self.request.host, self.request.port - if host and port: - self.server = TcpServerConnection(text_(host), port) - try: - logger.debug( - 'Connecting to upstream %s:%s' % - (text_(host), port)) - self.server.connect() - self.server.connection.setblocking(False) - logger.debug( - 'Connected to upstream %s:%s' % - (text_(host), port)) - except Exception as e: # TimeoutError, socket.gaierror - self.server.closed = True - raise ProxyConnectionFailed(text_(host), port, repr(e)) from e - else: - logger.exception('Both host and port must exist') - raise ProtocolException() - - -class WebsocketFrame: - """Websocket frames parser and constructor.""" - - GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' - - def __init__(self) -> None: - self.fin: bool = False - self.rsv1: bool = False - self.rsv2: bool = False - self.rsv3: bool = False - self.opcode: int = 0 - self.masked: bool = False - self.payload_length: Optional[int] = None - self.mask: Optional[bytes] = None - self.data: Optional[bytes] = None - - def reset(self) -> None: - self.fin = False - self.rsv1 = False - self.rsv2 = False - self.rsv3 = False - self.opcode = 0 - self.masked = False - self.payload_length = None - self.mask = None - self.data = None - - def parse_fin_and_rsv(self, byte: int) -> None: - self.fin = bool(byte & 1 << 7) - self.rsv1 = bool(byte & 1 << 6) - self.rsv2 = bool(byte & 1 << 5) - self.rsv3 = bool(byte & 1 << 4) - self.opcode = byte & 0b00001111 - - def parse_mask_and_payload(self, byte: int) -> None: - self.masked = bool(byte & 0b10000000) - self.payload_length = byte & 0b01111111 - - def build(self) -> bytes: - if self.payload_length is None and self.data: - self.payload_length = len(self.data) - raw = io.BytesIO() - raw.write( - struct.pack( - '!B', - (1 << 7 if self.fin else 0) | - (1 << 6 if self.rsv1 else 0) | - (1 << 5 if self.rsv2 else 0) | - (1 << 4 if self.rsv3 else 0) | - self.opcode - )) - assert self.payload_length is not None - if self.payload_length < 126: - raw.write( - struct.pack( - '!B', - (1 << 7 if self.masked else 0) | self.payload_length - ) - ) - elif self.payload_length < 1 << 16: - raw.write( - struct.pack( - '!BH', - (1 << 7 if self.masked else 0) | 126, - self.payload_length - ) - ) - elif self.payload_length < 1 << 64: - raw.write( - struct.pack( - '!BHQ', - (1 << 7 if self.masked else 0) | 127, - self.payload_length - ) - ) - else: - raise ValueError(f'Invalid payload_length { self.payload_length },' - f'maximum allowed { 1 << 64 }') - if self.masked and self.data: - mask = secrets.token_bytes(4) if self.mask is None else self.mask - raw.write(mask) - raw.write(self.apply_mask(self.data, mask)) - elif self.data: - raw.write(self.data) - return raw.getvalue() - - def parse(self, raw: bytes) -> bytes: - cur = 0 - self.parse_fin_and_rsv(raw[cur]) - cur += 1 - - self.parse_mask_and_payload(raw[cur]) - cur += 1 - - if self.payload_length == 126: - data = raw[cur: cur + 2] - self.payload_length, = struct.unpack('!H', data) - cur += 2 - elif self.payload_length == 127: - data = raw[cur: cur + 8] - self.payload_length, = struct.unpack('!Q', data) - cur += 8 - - if self.masked: - self.mask = raw[cur: cur + 4] - cur += 4 - - assert self.payload_length - self.data = raw[cur: cur + self.payload_length] - cur += self.payload_length - if self.masked: - assert self.mask is not None - self.data = self.apply_mask(self.data, self.mask) - - return raw[cur:] - - @staticmethod - def apply_mask(data: bytes, mask: bytes) -> bytes: - raw = bytearray(data) - for i in range(len(raw)): - raw[i] = raw[i] ^ mask[i % 4] - return bytes(raw) - - @staticmethod - def key_to_accept(key: bytes) -> bytes: - sha1 = hashlib.sha1() - sha1.update(key + WebsocketFrame.GUID) - return base64.b64encode(sha1.digest()) - - -class WebsocketClient(TcpConnection): - - def __init__(self, - hostname: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], - port: int, - path: bytes = b'/', - on_message: Optional[Callable[[WebsocketFrame], None]] = None) -> None: - super().__init__(tcpConnectionTypes.CLIENT) - self.hostname: Union[ipaddress.IPv4Address, ipaddress.IPv6Address] = hostname - self.port: int = port - self.path: bytes = path - self.sock: socket.socket = new_socket_connection((str(self.hostname), self.port)) - self.on_message: Optional[Callable[[WebsocketFrame], None]] = on_message - self.upgrade() - self.sock.setblocking(False) - self.selector: selectors.DefaultSelector = selectors.DefaultSelector() - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - return self.sock - - def upgrade(self) -> None: - key = base64.b64encode(secrets.token_bytes(16)) - self.sock.send(build_websocket_handshake_request(key, url=self.path)) - response = HttpParser(httpParserTypes.RESPONSE_PARSER) - response.parse(self.sock.recv(DEFAULT_BUFFER_SIZE)) - accept = response.header(b'Sec-Websocket-Accept') - assert WebsocketFrame.key_to_accept(key) == accept - - def ping(self, data: Optional[bytes] = None) -> None: - pass - - def pong(self, data: Optional[bytes] = None) -> None: - pass - - def shutdown(self, _data: Optional[bytes] = None) -> None: - """Closes connection with the server.""" - super().close() - - def run_once(self) -> bool: - ev = selectors.EVENT_READ - if self.has_buffer(): - ev |= selectors.EVENT_WRITE - self.selector.register(self.sock.fileno(), ev) - events = self.selector.select(timeout=1) - self.selector.unregister(self.sock) - for key, mask in events: - if mask & selectors.EVENT_READ and self.on_message: - raw = self.recv() - if raw is None or raw == b'': - self.closed = True - logger.debug('Websocket connection closed by server') - return True - frame = WebsocketFrame() - frame.parse(raw) - self.on_message(frame) - elif mask & selectors.EVENT_WRITE: - logger.debug(self.buffer) - self.flush() - return False - - def run(self) -> None: - logger.debug('running') - try: - while not self.closed: - teardown = self.run_once() - if teardown: - break - except KeyboardInterrupt: - pass - finally: - try: - self.selector.unregister(self.sock) - self.sock.shutdown(socket.SHUT_WR) - except Exception as e: - logging.exception('Exception while shutdown of websocket client', exc_info=e) - self.sock.close() - logger.info('done') - - -class HttpWebServerBasePlugin(ABC): - """Web Server Plugin for routing of requests.""" - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection): - self.config = config - self.client = client - - @abstractmethod - def routes(self) -> List[Tuple[int, bytes]]: - """Return List(protocol, path) that this plugin handles.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def handle_request(self, request: HttpParser) -> None: - """Handle the request and serve response.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def on_websocket_open(self) -> None: - """Called when websocket handshake has finished.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def on_websocket_message(self, frame: WebsocketFrame) -> None: - """Handle websocket frame.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def on_websocket_close(self) -> None: - """Called when websocket connection has been closed.""" - raise NotImplementedError() # pragma: no cover - - -class DevtoolsWebsocketPlugin(HttpWebServerBasePlugin): - """DevtoolsWebsocketPlugin handles Devtools Frontend websocket requests. - - For every connected Devtools Frontend instance, a dispatcher thread is - started which drains the global Devtools protocol events queue. - - Dispatcher thread is terminated when Devtools Frontend disconnects.""" - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection): - super().__init__(config, client) - self.event_dispatcher_thread: Optional[threading.Thread] = None - self.event_dispatcher_shutdown: Optional[threading.Event] = None - - def start_dispatcher(self) -> None: - self.event_dispatcher_shutdown = threading.Event() - assert self.config.devtools_event_queue is not None - self.event_dispatcher_thread = threading.Thread( - target=DevtoolsWebsocketPlugin.event_dispatcher, - args=(self.event_dispatcher_shutdown, - self.config.devtools_event_queue, - self.client)) - # self.event_dispatcher_thread.setDaemon(True) - self.event_dispatcher_thread.start() - - def stop_dispatcher(self) -> None: - assert self.event_dispatcher_shutdown is not None - assert self.event_dispatcher_thread is not None - self.event_dispatcher_shutdown.set() - self.event_dispatcher_thread.join() - logger.debug('Event dispatcher shutdown') - - @staticmethod - def event_dispatcher( - shutdown: threading.Event, - devtools_event_queue: DevtoolsEventQueueType, - client: TcpClientConnection) -> None: - while not shutdown.is_set(): - try: - ev = devtools_event_queue.get(timeout=1) - frame = WebsocketFrame() - frame.fin = True - frame.opcode = websocketOpcodes.TEXT_FRAME - frame.data = bytes_(json.dumps(ev)) - logger.debug(ev) - client.queue(frame.build()) - except queue.Empty: - pass - except Exception as e: - logger.exception('Event dispatcher exception', exc_info=e) - break - except KeyboardInterrupt: - break - - def routes(self) -> List[Tuple[int, bytes]]: - return [ - (httpProtocolTypes.WEBSOCKET, self.config.devtools_ws_path) - ] - - def handle_request(self, request: HttpParser) -> None: - pass - - def on_websocket_open(self) -> None: - self.start_dispatcher() - - def on_websocket_message(self, frame: WebsocketFrame) -> None: - if frame.data: - message = json.loads(frame.data) - self.handle_message(message) - else: - logger.debug('No data found in frame') - - def on_websocket_close(self) -> None: - self.stop_dispatcher() - - def handle_message(self, message: Dict[str, Any]) -> None: - frame = WebsocketFrame() - frame.fin = True - frame.opcode = websocketOpcodes.TEXT_FRAME - - if message['method'] in ( - 'Page.canScreencast', - 'Network.canEmulateNetworkConditions', - 'Emulation.canEmulate' - ): - data = json.dumps({ - 'id': message['id'], - 'result': False - }) - elif message['method'] == 'Page.getResourceTree': - data = json.dumps({ - 'id': message['id'], - 'result': { - 'frameTree': { - 'frame': { - 'id': 1, - 'url': 'http://proxypy', - 'mimeType': 'other', - }, - 'childFrames': [], - 'resources': [] - } - } - }) - elif message['method'] == 'Network.getResponseBody': - logger.debug('received request method Network.getResponseBody') - data = json.dumps({ - 'id': message['id'], - 'result': { - 'body': '', - 'base64Encoded': False, - } - }) - else: - data = json.dumps({ - 'id': message['id'], - 'result': {}, - }) - - frame.data = bytes_(data) - self.client.queue(frame.build()) - - -class HttpWebServerPacFilePlugin(HttpWebServerBasePlugin): - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection): - super().__init__(config, client) - self.pac_file_response: Optional[bytes] = None - self.cache_pac_file_response() - - def cache_pac_file_response(self) -> None: - if self.config.pac_file: - try: - with open(self.config.pac_file, 'rb') as f: - content = f.read() - except IOError: - content = bytes_(self.config.pac_file) - self.pac_file_response = build_http_response( - 200, reason=b'OK', headers={ - b'Content-Type': b'application/x-ns-proxy-autoconfig', - }, body=content - ) - - def routes(self) -> List[Tuple[int, bytes]]: - if self.config.pac_file_url_path: - return [ - (httpProtocolTypes.HTTP, bytes_(self.config.pac_file_url_path)), - (httpProtocolTypes.HTTPS, bytes_(self.config.pac_file_url_path)), - ] - return [] # pragma: no cover - - def handle_request(self, request: HttpParser) -> None: - if self.config.pac_file and self.pac_file_response: - self.client.queue(self.pac_file_response) - - def on_websocket_open(self) -> None: - pass # pragma: no cover - - def on_websocket_message(self, frame: WebsocketFrame) -> None: - pass # pragma: no cover - - def on_websocket_close(self) -> None: - pass # pragma: no cover - - -class HttpWebServerPlugin(ProtocolHandlerPlugin): - """ProtocolHandler plugin which handles incoming requests to local web server.""" - - DEFAULT_404_RESPONSE = build_http_response( - httpStatusCodes.NOT_FOUND, - reason=b'NOT FOUND', - headers={b'Server': PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close'} - ) - - DEFAULT_501_RESPONSE = build_http_response( - httpStatusCodes.NOT_IMPLEMENTED, - reason=b'NOT IMPLEMENTED', - headers={b'Server': PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close'} - ) - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection, - request: HttpParser): - super().__init__(config, client, request) - self.start_time: float = time.time() - self.pipeline_request: Optional[HttpParser] = None - self.switched_protocol: Optional[int] = None - self.routes: Dict[int, Dict[bytes, HttpWebServerBasePlugin]] = { - httpProtocolTypes.HTTP: {}, - httpProtocolTypes.HTTPS: {}, - httpProtocolTypes.WEBSOCKET: {}, - } - self.route: Optional[HttpWebServerBasePlugin] = None - - if b'HttpWebServerBasePlugin' in self.config.plugins: - for klass in self.config.plugins[b'HttpWebServerBasePlugin']: - instance = klass(self.config, self.client) - for (protocol, path) in instance.routes(): - self.routes[protocol][path] = instance - - def serve_file_or_404(self, path: str) -> bool: - """Read and serves a file from disk. - - Queues 404 Not Found for IOError. - Shouldn't this be server error? - """ - try: - with open(path, 'rb') as f: - content = f.read() - content_type = mimetypes.guess_type(path)[0] - if content_type is None: - content_type = 'text/plain' - self.client.queue(build_http_response( - httpStatusCodes.OK, - reason=b'OK', - headers={ - b'Content-Type': bytes_(content_type), - }, - body=content)) - return False - except IOError: - self.client.queue(self.DEFAULT_404_RESPONSE) - return True - - def try_upgrade(self) -> bool: - if self.request.has_header(b'connection') and \ - self.request.header(b'connection').lower() == b'upgrade': - if self.request.has_header(b'upgrade') and \ - self.request.header(b'upgrade').lower() == b'websocket': - self.client.queue( - build_websocket_handshake_response( - WebsocketFrame.key_to_accept( - self.request.header(b'Sec-WebSocket-Key')))) - self.switched_protocol = httpProtocolTypes.WEBSOCKET - else: - self.client.queue(self.DEFAULT_501_RESPONSE) - return True - return False - - def on_request_complete(self) -> Union[socket.socket, bool]: - if self.request.has_upstream_server(): - return False - - # If a websocket route exists for the path, try upgrade - if self.request.path in self.routes[httpProtocolTypes.WEBSOCKET]: - self.route = self.routes[httpProtocolTypes.WEBSOCKET][self.request.path] - - # Connection upgrade - teardown = self.try_upgrade() - if teardown: - return True - - # For upgraded connections, nothing more to do - if self.switched_protocol: - # Invoke plugin.on_websocket_open - self.route.on_websocket_open() - return False - - # Routing for Http(s) requests - protocol = httpProtocolTypes.HTTPS \ - if self.config.encryption_enabled() else \ - httpProtocolTypes.HTTP - for r in self.routes[protocol]: - if r == self.request.path: - self.route = self.routes[protocol][r] - self.route.handle_request(self.request) - return False - - # No-route found, try static serving if enabled - if self.config.enable_static_server: - path = text_(self.request.path).split('?')[0] - if os.path.isfile(self.config.static_server_dir + path): - return self.serve_file_or_404(self.config.static_server_dir + path) - - # Catch all unhandled web server requests, return 404 - self.client.queue(self.DEFAULT_404_RESPONSE) - return True - - def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: - pass - - def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: - pass - - def on_client_data(self, raw: bytes) -> Optional[bytes]: - if self.switched_protocol == httpProtocolTypes.WEBSOCKET: - remaining = raw - frame = WebsocketFrame() - while remaining != b'': - # TODO: Teardown if invalid protocol exception - remaining = frame.parse(remaining) - for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == self.request.path: - self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_message(frame) - frame.reset() - return None - # If 1st valid request was completed and it's a HTTP/1.1 keep-alive - # And only if we have a route, parse pipeline requests - elif self.request.state == httpParserStates.COMPLETE and \ - self.request.is_http_1_1_keep_alive() and \ - self.route is not None: - if self.pipeline_request is None: - self.pipeline_request = HttpParser(httpParserTypes.REQUEST_PARSER) - self.pipeline_request.parse(raw) - if self.pipeline_request.state == httpParserStates.COMPLETE: - self.route.handle_request(self.pipeline_request) - if not self.pipeline_request.is_http_1_1_keep_alive(): - logger.error('Pipelined request is not keep-alive, will teardown request...') - raise ProtocolException() - self.pipeline_request = None - return raw - - def on_response_chunk(self, chunk: bytes) -> bytes: - return chunk - - def on_client_connection_close(self) -> None: - if self.request.has_upstream_server(): - return - if self.switched_protocol: - # Invoke plugin.on_websocket_close - for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == self.request.path: - self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_close() - self.access_log() - - def access_log(self) -> None: - logger.info( - '%s:%s - %s %s - %.2f ms' % - (self.client.addr[0], - self.client.addr[1], - text_(self.request.method), - text_(self.request.path), - (time.time() - self.start_time) * 1000)) - - def get_descriptors( - self) -> Tuple[List[socket.socket], List[socket.socket]]: - return [], [] - - -class ProtocolHandler(threading.Thread, ThreadlessWork): - """HTTP, HTTPS, HTTP2, WebSockets protocol handler. - - Accepts `Client` connection object and manages ProtocolHandlerPlugin invocations. - """ - - def __init__(self, fileno: int, addr: Tuple[str, int], - config: Optional[ProtocolConfig] = None): - super().__init__() - self.fileno: int = fileno - self.addr: Tuple[str, int] = addr - - self.start_time: float = time.time() - self.last_activity: float = self.start_time - - self.config: ProtocolConfig = config if config else ProtocolConfig() - self.request: HttpParser = HttpParser(httpParserTypes.REQUEST_PARSER) - self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) - - self.selector = selectors.DefaultSelector() - self.client: TcpClientConnection = TcpClientConnection( - self.fromfd(self.fileno), self.addr - ) - self.plugins: Dict[str, ProtocolHandlerPlugin] = {} - - def initialize(self) -> None: - """Optionally upgrades connection to HTTPS, set conn in non-blocking mode and initializes plugins.""" - conn = self.optionally_wrap_socket(self.client.connection) - conn.setblocking(False) - if self.config.encryption_enabled(): - self.client = TcpClientConnection(conn=conn, addr=self.addr) - if b'ProtocolHandlerPlugin' in self.config.plugins: - for klass in self.config.plugins[b'ProtocolHandlerPlugin']: - instance = klass(self.config, self.client, self.request) - self.plugins[instance.name()] = instance - logger.debug('Handling connection %r' % self.client.connection) - - def is_inactive(self) -> bool: - if not self.client.has_buffer() and \ - self.connection_inactive_for() > self.config.timeout: - return True - return False - - def get_events(self) -> Dict[socket.socket, int]: - events: Dict[socket.socket, int] = { - self.client.connection: selectors.EVENT_READ - } - if self.client.has_buffer(): - events[self.client.connection] |= selectors.EVENT_WRITE - - # ProtocolHandlerPlugin.get_descriptors - for plugin in self.plugins.values(): - plugin_read_desc, plugin_write_desc = plugin.get_descriptors() - for r in plugin_read_desc: - if r not in events: - events[r] = selectors.EVENT_READ - else: - events[r] |= selectors.EVENT_READ - for w in plugin_write_desc: - if w not in events: - events[w] = selectors.EVENT_WRITE - else: - events[w] |= selectors.EVENT_WRITE - - return events - - def handle_events( - self, - readables: List[Union[int, _HasFileno]], - writables: List[Union[int, _HasFileno]]) -> bool: - """Returns True if proxy must teardown.""" - # Flush buffer for ready to write sockets - teardown = self.handle_writables(writables) - if teardown: - return True - - # Invoke plugin.write_to_descriptors - for plugin in self.plugins.values(): - teardown = plugin.write_to_descriptors(writables) - if teardown: - return True - - # Read from ready to read sockets - teardown = self.handle_readables(readables) - if teardown: - return True - - # Invoke plugin.read_from_descriptors - for plugin in self.plugins.values(): - teardown = plugin.read_from_descriptors(readables) - if teardown: - return True - - return False - - def shutdown(self) -> None: - # Flush pending buffer if any - self.flush() - - # Invoke plugin.on_client_connection_close - for plugin in self.plugins.values(): - plugin.on_client_connection_close() - - logger.debug( - 'Closing client connection %r ' - 'at address %r with pending client buffer size %d bytes' % - (self.client.connection, self.client.addr, self.client.buffer_size())) - - conn = self.client.connection - try: - # Unwrap if wrapped before shutdown. - if self.config.encryption_enabled() and \ - isinstance(self.client.connection, ssl.SSLSocket): - conn = self.client.connection.unwrap() - conn.shutdown(socket.SHUT_WR) - logger.debug('Client connection shutdown successful') - except OSError: - pass - finally: - conn.close() - logger.debug('Client connection closed') - - def fromfd(self, fileno: int) -> socket.socket: - conn = socket.fromfd( - fileno, family=socket.AF_INET if self.config.hostname.version == 4 else socket.AF_INET6, - type=socket.SOCK_STREAM) - return conn - - def optionally_wrap_socket( - self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]: - """Attempts to wrap accepted client connection using provided certificates. - - Shutdown and closes client connection upon error. - """ - if self.config.encryption_enabled(): - ctx = ssl.create_default_context( - ssl.Purpose.CLIENT_AUTH) - ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 - ctx.verify_mode = ssl.CERT_NONE - assert self.config.keyfile and self.config.certfile - ctx.load_cert_chain( - certfile=self.config.certfile, - keyfile=self.config.keyfile) - conn = ctx.wrap_socket(conn, server_side=True) - return conn - - def connection_inactive_for(self) -> float: - return time.time() - self.last_activity - - def flush(self) -> None: - if not self.client.has_buffer(): - return - try: - self.selector.register(self.client.connection, selectors.EVENT_WRITE) - while self.client.has_buffer(): - ev: List[Tuple[selectors.SelectorKey, int]] = self.selector.select(timeout=1) - if len(ev) == 0: - continue - self.client.flush() - except BrokenPipeError: - pass - finally: - self.selector.unregister(self.client.connection) - - def handle_writables(self, writables: List[Union[int, _HasFileno]]) -> bool: - if self.client.buffer_size() > 0 and self.client.connection in writables: - logger.debug('Client is ready for writes, flushing buffer') - self.last_activity = time.time() - - # Invoke plugin.on_response_chunk - chunk = self.client.buffer - for plugin in self.plugins.values(): - chunk = plugin.on_response_chunk(chunk) - if chunk is None: - break - - try: - self.client.flush() - except OSError: - logger.error('OSError when flushing buffer to client') - return True - except BrokenPipeError: - logger.error( - 'BrokenPipeError when flushing buffer for client') - return True - return False - - def handle_readables(self, readables: List[Union[int, _HasFileno]]) -> bool: - if self.client.connection in readables: - logger.debug('Client is ready for reads, reading') - self.last_activity = time.time() - client_data: Optional[bytes] = None - - try: - client_data = self.client.recv(self.config.client_recvbuf_size) - except ssl.SSLWantReadError: # Try again later - logger.warning('SSLWantReadError encountered while reading from client, will retry ...') - return False - except socket.error as e: - if e.errno == errno.ECONNRESET: - logger.warning('%r' % e) - else: - logger.exception( - 'Exception while receiving from %s connection %r with reason %r' % - (self.client.tag, self.client.connection, e)) - return True - - if not client_data: - logger.debug('Client closed connection, tearing down...') - self.client.closed = True - return True - - try: - # ProtocolHandlerPlugin.on_client_data - # Can raise ProtocolException to teardown the connection - plugin_index = 0 - plugins = list(self.plugins.values()) - while plugin_index < len(plugins) and client_data: - client_data = plugins[plugin_index].on_client_data(client_data) - if client_data is None: - break - plugin_index += 1 - - # Don't parse request any further after 1st request has completed. - # This specially does happen for pipeline requests. - # Plugins can utilize on_client_data for such cases and - # apply custom logic to handle request data sent after 1st valid request. - if client_data and self.request.state != httpParserStates.COMPLETE: - # Parse http request - self.request.parse(client_data) - if self.request.state == httpParserStates.COMPLETE: - # Invoke plugin.on_request_complete - for plugin in self.plugins.values(): - upgraded_sock = plugin.on_request_complete() - if isinstance(upgraded_sock, ssl.SSLSocket): - logger.debug( - 'Updated client conn to %s', upgraded_sock) - self.client._conn = upgraded_sock - for plugin_ in self.plugins.values(): - if plugin_ != plugin: - plugin_.client._conn = upgraded_sock - elif isinstance(upgraded_sock, bool) and upgraded_sock is True: - return True - except ProtocolException as e: - logger.exception( - 'ProtocolException type raised', exc_info=e) - response = e.response(self.request) - if response: - self.client.queue(response) - return True - return False - - @contextlib.contextmanager - def selected_events(self) -> \ - Generator[Tuple[List[Union[int, _HasFileno]], - List[Union[int, _HasFileno]]], - None, None]: - events = self.get_events() - for fd in events: - self.selector.register(fd, events[fd]) - ev = self.selector.select(timeout=1) - readables = [] - writables = [] - for key, mask in ev: - if mask & selectors.EVENT_READ: - readables.append(key.fileobj) - if mask & selectors.EVENT_WRITE: - writables.append(key.fileobj) - yield (readables, writables) - for fd in events.keys(): - self.selector.unregister(fd) - - def run_once(self) -> bool: - with self.selected_events() as (readables, writables): - teardown = self.handle_events(readables, writables) - if teardown: - return True - return False - - def run(self) -> None: - try: - self.initialize() - while True: - # Teardown if client buffer is empty and connection is inactive - if self.is_inactive(): - logger.debug( - 'Client buffer is empty and maximum inactivity has reached ' - 'between client and server connection, tearing down...') - break - teardown = self.run_once() - if teardown: - break - except KeyboardInterrupt: # pragma: no cover - pass - except ssl.SSLError as e: - logger.exception('ssl.SSLError', exc_info=e) - except Exception as e: - logger.exception( - 'Exception while handling connection %r' % - self.client.connection, exc_info=e) - finally: - self.shutdown() - - -class DevtoolsProtocolPlugin(ProtocolHandlerPlugin): - """ - DevtoolsProtocolPlugin taps into core `ProtocolHandler` - events and converts them into Devtools Protocol json messages. - - A DevtoolsProtocolPlugin instance is created per request. - Per request devtool events are queued into a global multiprocessing queue. - """ - - frame_id = secrets.token_hex(8) - loader_id = secrets.token_hex(8) - - def __init__( - self, - config: ProtocolConfig, - client: TcpClientConnection, - request: HttpParser): - self.id: str = f'{ os.getpid() }-{ threading.get_ident() }-{ time.time() }' - self.response = HttpParser(httpParserTypes.RESPONSE_PARSER) - super().__init__(config, client, request) - - def get_descriptors(self) -> Tuple[List[socket.socket], List[socket.socket]]: - return [], [] - - def write_to_descriptors(self, w: List[Union[int, _HasFileno]]) -> bool: - return False - - def read_from_descriptors(self, r: List[Union[int, _HasFileno]]) -> bool: - return False - - def on_client_data(self, raw: bytes) -> Optional[bytes]: - return raw - - def on_request_complete(self) -> Union[socket.socket, bool]: - if not self.request.has_upstream_server() and \ - self.request.path == self.config.devtools_ws_path: - return False - - # Handle devtool frontend websocket upgrade - if self.config.devtools_event_queue: - self.config.devtools_event_queue.put({ - 'method': 'Network.requestWillBeSent', - 'params': self.request_will_be_sent(), - }) - return False - - def on_response_chunk(self, chunk: bytes) -> bytes: - if not self.request.has_upstream_server() and \ - self.request.path == self.config.devtools_ws_path: - return chunk - - if self.config.devtools_event_queue: - self.response.parse(chunk) - if self.response.state >= httpParserStates.HEADERS_COMPLETE: - self.config.devtools_event_queue.put({ - 'method': 'Network.responseReceived', - 'params': self.response_received(), - }) - if self.response.state >= httpParserStates.RCVING_BODY: - self.config.devtools_event_queue.put({ - 'method': 'Network.dataReceived', - 'params': self.data_received(chunk) - }) - if self.response.state == httpParserStates.COMPLETE: - self.config.devtools_event_queue.put({ - 'method': 'Network.loadingFinished', - 'params': self.loading_finished() - }) - return chunk - - def on_client_connection_close(self) -> None: - pass - - def request_will_be_sent(self) -> Dict[str, Any]: - now = time.time() - return { - 'requestId': self.id, - 'loaderId': self.loader_id, - 'documentURL': 'http://proxy-py', - 'request': { - 'url': text_( - self.request.path - if self.request.has_upstream_server() else - b'http://' + bytes_(str(self.config.hostname)) + - COLON + bytes_(self.config.port) + self.request.path - ), - 'urlFragment': '', - 'method': text_(self.request.method), - 'headers': {text_(v[0]): text_(v[1]) for v in self.request.headers.values()}, - 'initialPriority': 'High', - 'mixedContentType': 'none', - 'postData': None if self.request.method != 'POST' - else text_(self.request.body) - }, - 'timestamp': now - PROXY_PY_START_TIME, - 'wallTime': now, - 'initiator': { - 'type': 'other' - }, - 'type': text_(self.request.header(b'content-type')) - if self.request.has_header(b'content-type') - else 'Other', - 'frameId': self.frame_id, - 'hasUserGesture': False - } - - def response_received(self) -> Dict[str, Any]: - return { - 'requestId': self.id, - 'frameId': self.frame_id, - 'loaderId': self.loader_id, - 'timestamp': time.time(), - 'type': text_(self.response.header(b'content-type')) - if self.response.has_header(b'content-type') - else 'Other', - 'response': { - 'url': '', - 'status': '', - 'statusText': '', - 'headers': '', - 'headersText': '', - 'mimeType': '', - 'connectionReused': True, - 'connectionId': '', - 'encodedDataLength': '', - 'fromDiskCache': False, - 'fromServiceWorker': False, - 'timing': { - 'requestTime': '', - 'proxyStart': -1, - 'proxyEnd': -1, - 'dnsStart': -1, - 'dnsEnd': -1, - 'connectStart': -1, - 'connectEnd': -1, - 'sslStart': -1, - 'sslEnd': -1, - 'workerStart': -1, - 'workerReady': -1, - 'sendStart': 0, - 'sendEnd': 0, - 'receiveHeadersEnd': 0, - }, - 'requestHeaders': '', - 'remoteIPAddress': '', - 'remotePort': '', - } - } - - def data_received(self, chunk: bytes) -> Dict[str, Any]: - return { - 'requestId': self.id, - 'timestamp': time.time(), - 'dataLength': len(chunk), - 'encodedDataLength': len(chunk), - } - - def loading_finished(self) -> Dict[str, Any]: - return { - 'requestId': self.id, - 'timestamp': time.time(), - 'encodedDataLength': self.response.total_size - } - - -def is_py3() -> bool: - """Exists only to avoid mocking sys.version_info in tests.""" - return sys.version_info[0] == 3 - - -def set_open_file_limit(soft_limit: int) -> None: - """Configure open file description soft limit on supported OS.""" - if os.name != 'nt': # resource module not available on Windows OS - curr_soft_limit, curr_hard_limit = resource.getrlimit( - resource.RLIMIT_NOFILE) - if curr_soft_limit < soft_limit < curr_hard_limit: - resource.setrlimit( - resource.RLIMIT_NOFILE, (soft_limit, curr_hard_limit)) - logger.debug( - 'Open file descriptor soft limit set to %d' % - soft_limit) - - -def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]: - """Accepts a comma separated list of Python modules and returns - a list of respective Python classes.""" - p: Dict[bytes, List[type]] = { - b'ProtocolHandlerPlugin': [], - b'HttpProxyBasePlugin': [], - b'HttpWebServerBasePlugin': [], - } - for plugin_ in plugins.split(COMMA): - plugin = text_(plugin_.strip()) - if plugin == '': - continue - module_name, klass_name = plugin.rsplit(text_(DOT), 1) - klass = getattr( - importlib.import_module( - __name__ if module_name == 'proxy' else module_name), - klass_name) - base_klass = inspect.getmro(klass)[1] - p[bytes_(base_klass.__name__)].append(klass) - logger.info( - 'Loaded %s %s.%s', - 'plugin' if klass.__name__ != 'HttpWebServerRouteHandler' else 'route', - module_name, - # HttpWebServerRouteHandler route decorator adds a special - # staticmethod to return decorated function name - klass.__name__ if klass.__name__ != 'HttpWebServerRouteHandler' else klass.name()) - return p - - -def setup_logger( - log_file: Optional[str] = DEFAULT_LOG_FILE, - log_level: str = DEFAULT_LOG_LEVEL, - log_format: str = DEFAULT_LOG_FORMAT) -> None: - ll = getattr( - logging, - {'D': 'DEBUG', - 'I': 'INFO', - 'W': 'WARNING', - 'E': 'ERROR', - 'C': 'CRITICAL'}[log_level.upper()[0]]) - if log_file: - logging.basicConfig( - filename=log_file, - filemode='a', - level=ll, - format=log_format) - else: - logging.basicConfig(level=ll, format=log_format) - - -def init_parser() -> argparse.ArgumentParser: - """Initializes and returns argument parser.""" - parser = argparse.ArgumentParser( - description='proxy.py v%s' % __version__, - epilog='Proxy.py not working? Report at: %s/issues/new' % __homepage__ - ) - # Argument names are ordered alphabetically. - parser.add_argument( - '--backlog', - type=int, - default=DEFAULT_BACKLOG, - help='Default: 100. Maximum number of pending connections to proxy server') - parser.add_argument( - '--basic-auth', - type=str, - default=DEFAULT_BASIC_AUTH, - help='Default: No authentication. Specify colon separated user:password ' - 'to enable basic authentication.') - parser.add_argument( - '--ca-key-file', - type=str, - default=DEFAULT_CA_KEY_FILE, - help='Default: None. CA key to use for signing dynamically generated ' - 'HTTPS certificates. If used, must also pass --ca-cert-file and --ca-signing-key-file' - ) - parser.add_argument( - '--ca-cert-dir', - type=str, - default=DEFAULT_CA_CERT_DIR, - help='Default: ~/.proxy.py. Directory to store dynamically generated certificates. ' - 'Also see --ca-key-file, --ca-cert-file and --ca-signing-key-file' - ) - parser.add_argument( - '--ca-cert-file', - type=str, - default=DEFAULT_CA_CERT_FILE, - help='Default: None. Signing certificate to use for signing dynamically generated ' - 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file' - ) - parser.add_argument( - '--ca-signing-key-file', - type=str, - default=DEFAULT_CA_SIGNING_KEY_FILE, - help='Default: None. CA signing key to use for dynamic generation of ' - 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-cert-file' - ) - parser.add_argument( - '--cert-file', - type=str, - default=DEFAULT_CERT_FILE, - help='Default: None. Server certificate to enable end-to-end TLS encryption with clients. ' - 'If used, must also pass --key-file.' - ) - parser.add_argument( - '--client-recvbuf-size', - type=int, - default=DEFAULT_CLIENT_RECVBUF_SIZE, - help='Default: 1 MB. Maximum amount of data received from the ' - 'client in a single recv() operation. Bump this ' - 'value for faster uploads at the expense of ' - 'increased RAM.') - parser.add_argument( - '--devtools-ws-path', - type=str, - default=DEFAULT_DEVTOOLS_WS_PATH, - help='Default: /devtools. Only applicable ' - 'if --enable-devtools is used.' - ) - parser.add_argument( - '--disable-headers', - type=str, - default=COMMA.join(DEFAULT_DISABLE_HEADERS), - help='Default: None. Comma separated list of headers to remove before ' - 'dispatching client request to upstream server.') - parser.add_argument( - '--disable-http-proxy', - action='store_true', - default=DEFAULT_DISABLE_HTTP_PROXY, - help='Default: False. Whether to disable proxy.HttpProxyPlugin.') - parser.add_argument( - '--enable-devtools', - action='store_true', - default=DEFAULT_ENABLE_DEVTOOLS, - help='Default: False. Enables integration with Chrome Devtool Frontend.' - ) - parser.add_argument( - '--enable-static-server', - action='store_true', - default=DEFAULT_ENABLE_STATIC_SERVER, - help='Default: False. Enable inbuilt static file server. ' - 'Optionally, also use --static-server-dir to serve static content ' - 'from custom directory. By default, static file server serves ' - 'from public folder.' - ) - parser.add_argument( - '--enable-web-server', - action='store_true', - default=DEFAULT_ENABLE_WEB_SERVER, - help='Default: False. Whether to enable proxy.HttpWebServerPlugin.') - parser.add_argument('--hostname', - type=str, - default=str(DEFAULT_IPV6_HOSTNAME), - help='Default: ::1. Server IP address.') - parser.add_argument( - '--key-file', - type=str, - default=DEFAULT_KEY_FILE, - help='Default: None. Server key file to enable end-to-end TLS encryption with clients. ' - 'If used, must also pass --cert-file.' - ) - parser.add_argument( - '--log-level', - type=str, - default=DEFAULT_LOG_LEVEL, - help='Valid options: DEBUG, INFO (default), WARNING, ERROR, CRITICAL. ' - 'Both upper and lowercase values are allowed. ' - 'You may also simply use the leading character e.g. --log-level d') - parser.add_argument('--log-file', type=str, default=DEFAULT_LOG_FILE, - help='Default: sys.stdout. Log file destination.') - parser.add_argument('--log-format', type=str, default=DEFAULT_LOG_FORMAT, - help='Log format for Python logger.') - parser.add_argument('--num-workers', type=int, default=DEFAULT_NUM_WORKERS, - help='Defaults to number of CPU cores.') - parser.add_argument( - '--open-file-limit', - type=int, - default=DEFAULT_OPEN_FILE_LIMIT, - help='Default: 1024. Maximum number of files (TCP connections) ' - 'that proxy.py can open concurrently.') - parser.add_argument( - '--pac-file', - type=str, - default=DEFAULT_PAC_FILE, - help='A file (Proxy Auto Configuration) or string to serve when ' - 'the server receives a direct file request. ' - 'Using this option enables proxy.HttpWebServerPlugin.') - parser.add_argument( - '--pac-file-url-path', - type=str, - default=text_(DEFAULT_PAC_FILE_URL_PATH), - help='Default: %s. Web server path to serve the PAC file.' % - text_(DEFAULT_PAC_FILE_URL_PATH)) - parser.add_argument( - '--pid-file', - type=str, - default=DEFAULT_PID_FILE, - help='Default: None. Save parent process ID to a file.') - parser.add_argument( - '--plugins', - type=str, - default=DEFAULT_PLUGINS, - help='Comma separated plugins') - parser.add_argument('--port', type=int, default=DEFAULT_PORT, - help='Default: 8899. Server port.') - parser.add_argument( - '--server-recvbuf-size', - type=int, - default=DEFAULT_SERVER_RECVBUF_SIZE, - help='Default: 1 MB. Maximum amount of data received from the ' - 'server in a single recv() operation. Bump this ' - 'value for faster downloads at the expense of ' - 'increased RAM.') - parser.add_argument( - '--static-server-dir', - type=str, - default=DEFAULT_STATIC_SERVER_DIR, - help='Default: "public" folder in directory where proxy.py is placed. ' - 'This option is only applicable when static server is also enabled. ' - 'See --enable-static-server.' - ) - parser.add_argument( - '--threadless', - action='store_true', - default=DEFAULT_THREADLESS, - help='Default: False. When disabled a new thread is spawned ' - 'to handle each client connection.' - ) - parser.add_argument( - '--timeout', - type=int, - default=DEFAULT_TIMEOUT, - help='Default: ' + str(DEFAULT_TIMEOUT) + '. Number of seconds after which ' - 'an inactive connection must be dropped. Inactivity is defined by no ' - 'data sent or received by the client.' - ) - parser.add_argument( - '--version', - '-v', - action='store_true', - default=DEFAULT_VERSION, - help='Prints proxy.py version.') - return parser - - -def main(input_args: List[str]) -> None: - if not is_py3() and not UNDER_TEST: - print( - 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' - 'If for some reasons you cannot upgrade, consider using "master" branch or simply ' - '"pip install proxy.py".' - '\n\n' - 'DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. ' - 'Please upgrade your Python as Python 2.7 won\'t be maintained after that date. ' - 'A future version of pip will drop support for Python 2.7.') - sys.exit(0) - - args = init_parser().parse_args(input_args) - - if args.version: - print(text_(version)) - sys.exit(0) - - if (args.cert_file and args.key_file) and \ - (args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file): - print('You can either enable end-to-end encryption OR TLS interception,' - 'not both together.') - sys.exit(0) - - try: - setup_logger(args.log_file, args.log_level, args.log_format) - set_open_file_limit(args.open_file_limit) - - auth_code = None - if args.basic_auth: - auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth)) - - default_plugins = '' - devtools_event_queue: Optional[DevtoolsEventQueueType] = None - if args.enable_devtools: - default_plugins += 'proxy.DevtoolsProtocolPlugin,' - default_plugins += 'proxy.HttpWebServerPlugin,' - if not args.disable_http_proxy: - default_plugins += 'proxy.HttpProxyPlugin,' - if args.enable_web_server or \ - args.pac_file is not None or \ - args.enable_static_server: - if 'proxy.HttpWebServerPlugin' not in default_plugins: - default_plugins += 'proxy.HttpWebServerPlugin,' - if args.enable_devtools: - default_plugins += 'proxy.DevtoolsWebsocketPlugin,' - devtools_event_queue = multiprocessing.Manager().Queue() - if args.pac_file is not None: - default_plugins += 'proxy.HttpWebServerPacFilePlugin,' - - config = ProtocolConfig( - auth_code=auth_code, - server_recvbuf_size=args.server_recvbuf_size, - client_recvbuf_size=args.client_recvbuf_size, - pac_file=bytes_(args.pac_file), - pac_file_url_path=bytes_(args.pac_file_url_path), - disable_headers=[ - header.lower() for header in bytes_( - args.disable_headers).split(COMMA) if header.strip() != b''], - certfile=args.cert_file, - keyfile=args.key_file, - ca_cert_dir=args.ca_cert_dir, - ca_key_file=args.ca_key_file, - ca_cert_file=args.ca_cert_file, - ca_signing_key_file=args.ca_signing_key_file, - hostname=ipaddress.ip_address(args.hostname), - port=args.port, - backlog=args.backlog, - num_workers=args.num_workers if args.num_workers > 0 else multiprocessing.cpu_count(), - static_server_dir=args.static_server_dir, - enable_static_server=args.enable_static_server, - devtools_event_queue=devtools_event_queue, - devtools_ws_path=args.devtools_ws_path, - timeout=args.timeout, - threadless=args.threadless) - - config.plugins = load_plugins( - bytes_( - '%s%s' % - (default_plugins, args.plugins))) - - acceptor_pool = AcceptorPool( - hostname=config.hostname, - port=config.port, - backlog=config.backlog, - num_workers=config.num_workers, - threadless=config.threadless, - work_klass=ProtocolHandler, - config=config) - if args.pid_file: - with open(args.pid_file, 'wb') as pid_file: - pid_file.write(bytes_(os.getpid())) - acceptor_pool.setup() - - try: - # TODO: Introduce cron feature instead of mindless sleep - while True: - time.sleep(1) - except Exception as e: - logger.exception('exception', exc_info=e) - finally: - acceptor_pool.shutdown() - except KeyboardInterrupt: # pragma: no cover - pass - finally: - if args.pid_file: - if os.path.exists(args.pid_file): - os.remove(args.pid_file) - - -if __name__ == '__main__': - main(sys.argv[1:]) # pragma: no cover diff --git a/proxy/__init__.py b/proxy/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/proxy/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/__main__.py b/proxy/__main__.py new file mode 100644 index 0000000000..50e913d7d2 --- /dev/null +++ b/proxy/__main__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .main import entry_point + +if __name__ == '__main__': + entry_point() diff --git a/proxy/common/__init__.py b/proxy/common/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/proxy/common/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/common/constants.py b/proxy/common/constants.py new file mode 100644 index 0000000000..fad7b97b9d --- /dev/null +++ b/proxy/common/constants.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import time +import ipaddress + +from typing import List + +from .version import __version__ + +__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' +__author__ = 'Abhinav Singh' +__author_email__ = 'mailsforabhinav@gmail.com' +__homepage__ = 'https://github.com/abhinavsingh/proxy.py' +__download_url__ = '%s/archive/master.zip' % __homepage__ +__license__ = 'BSD' + +PROXY_PY_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +PROXY_PY_START_TIME = time.time() + +CRLF = b'\r\n' +COLON = b':' +WHITESPACE = b' ' +COMMA = b',' +DOT = b'.' +SLASH = b'/' +HTTP_1_1 = b'HTTP/1.1' + +PROXY_AGENT_HEADER_KEY = b'Proxy-agent' +PROXY_AGENT_HEADER_VALUE = b'proxy.py v' + \ + __version__.encode('utf-8', 'strict') +PROXY_AGENT_HEADER = PROXY_AGENT_HEADER_KEY + \ + COLON + WHITESPACE + PROXY_AGENT_HEADER_VALUE + +# Defaults +DEFAULT_BACKLOG = 100 +DEFAULT_BASIC_AUTH = None +DEFAULT_BUFFER_SIZE = 1024 * 1024 +DEFAULT_CA_CERT_DIR = None +DEFAULT_CA_CERT_FILE = None +DEFAULT_CA_KEY_FILE = None +DEFAULT_CA_SIGNING_KEY_FILE = None +DEFAULT_CERT_FILE = None +DEFAULT_CLIENT_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE +DEFAULT_DEVTOOLS_WS_PATH = b'/devtools' +DEFAULT_DISABLE_HEADERS: List[bytes] = [] +DEFAULT_DISABLE_HTTP_PROXY = False +DEFAULT_ENABLE_DEVTOOLS = False +DEFAULT_ENABLE_EVENTS = False +DEFAULT_EVENTS_QUEUE = None +DEFAULT_ENABLE_STATIC_SERVER = False +DEFAULT_ENABLE_WEB_SERVER = False +DEFAULT_IPV4_HOSTNAME = ipaddress.IPv4Address('127.0.0.1') +DEFAULT_IPV6_HOSTNAME = ipaddress.IPv6Address('::1') +DEFAULT_KEY_FILE = None +DEFAULT_LOG_FILE = None +DEFAULT_LOG_FORMAT = '%(asctime)s - pid:%(process)d [%(levelname)-.1s] %(funcName)s:%(lineno)d - %(message)s' +DEFAULT_LOG_LEVEL = 'INFO' +DEFAULT_NUM_WORKERS = 0 +DEFAULT_OPEN_FILE_LIMIT = 1024 +DEFAULT_PAC_FILE = None +DEFAULT_PAC_FILE_URL_PATH = b'/' +DEFAULT_PID_FILE = None +DEFAULT_PLUGINS = '' +DEFAULT_PORT = 8899 +DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE +DEFAULT_STATIC_SERVER_DIR = os.path.join( + os.path.dirname(PROXY_PY_DIR), 'public') +DEFAULT_THREADLESS = False +DEFAULT_TIMEOUT = 10 +DEFAULT_VERSION = False diff --git a/proxy/common/flags.py b/proxy/common/flags.py new file mode 100644 index 0000000000..a3e5b2e5c8 --- /dev/null +++ b/proxy/common/flags.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import argparse +import ipaddress +import os +import socket +import multiprocessing +import pathlib + +from typing import Optional, Union, Dict, List + +from .utils import text_ +from .types import DictQueueType +from .constants import DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_BACKLOG, DEFAULT_BASIC_AUTH +from .constants import DEFAULT_TIMEOUT, DEFAULT_DEVTOOLS_WS_PATH, DEFAULT_DISABLE_HTTP_PROXY, DEFAULT_DISABLE_HEADERS +from .constants import DEFAULT_ENABLE_STATIC_SERVER, DEFAULT_ENABLE_EVENTS, DEFAULT_ENABLE_DEVTOOLS +from .constants import DEFAULT_ENABLE_WEB_SERVER, DEFAULT_THREADLESS, DEFAULT_CERT_FILE, DEFAULT_KEY_FILE +from .constants import DEFAULT_CA_CERT_DIR, DEFAULT_CA_CERT_FILE, DEFAULT_CA_KEY_FILE, DEFAULT_CA_SIGNING_KEY_FILE +from .constants import DEFAULT_PAC_FILE_URL_PATH, DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT +from .constants import DEFAULT_NUM_WORKERS, DEFAULT_VERSION, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME +from .constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE, DEFAULT_STATIC_SERVER_DIR +from .constants import COMMA +from .constants import __homepage__ +from .version import __version__ + + +def init_parser() -> argparse.ArgumentParser: + """Initializes and returns argument parser.""" + parser = argparse.ArgumentParser( + description='proxy.py v%s' % __version__, + epilog='Proxy.py not working? Report at: %s/issues/new' % __homepage__ + ) + # Argument names are ordered alphabetically. + parser.add_argument( + '--backlog', + type=int, + default=DEFAULT_BACKLOG, + help='Default: 100. Maximum number of pending connections to proxy server') + parser.add_argument( + '--basic-auth', + type=str, + default=DEFAULT_BASIC_AUTH, + help='Default: No authentication. Specify colon separated user:password ' + 'to enable basic authentication.') + parser.add_argument( + '--ca-key-file', + type=str, + default=DEFAULT_CA_KEY_FILE, + help='Default: None. CA key to use for signing dynamically generated ' + 'HTTPS certificates. If used, must also pass --ca-cert-file and --ca-signing-key-file' + ) + parser.add_argument( + '--ca-cert-dir', + type=str, + default=DEFAULT_CA_CERT_DIR, + help='Default: ~/.proxy.py. Directory to store dynamically generated certificates. ' + 'Also see --ca-key-file, --ca-cert-file and --ca-signing-key-file' + ) + parser.add_argument( + '--ca-cert-file', + type=str, + default=DEFAULT_CA_CERT_FILE, + help='Default: None. Signing certificate to use for signing dynamically generated ' + 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file' + ) + parser.add_argument( + '--ca-signing-key-file', + type=str, + default=DEFAULT_CA_SIGNING_KEY_FILE, + help='Default: None. CA signing key to use for dynamic generation of ' + 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-cert-file' + ) + parser.add_argument( + '--cert-file', + type=str, + default=DEFAULT_CERT_FILE, + help='Default: None. Server certificate to enable end-to-end TLS encryption with clients. ' + 'If used, must also pass --key-file.' + ) + parser.add_argument( + '--client-recvbuf-size', + type=int, + default=DEFAULT_CLIENT_RECVBUF_SIZE, + help='Default: 1 MB. Maximum amount of data received from the ' + 'client in a single recv() operation. Bump this ' + 'value for faster uploads at the expense of ' + 'increased RAM.') + parser.add_argument( + '--devtools-ws-path', + type=str, + default=DEFAULT_DEVTOOLS_WS_PATH, + help='Default: /devtools. Only applicable ' + 'if --enable-devtools is used.' + ) + parser.add_argument( + '--disable-headers', + type=str, + default=COMMA.join(DEFAULT_DISABLE_HEADERS), + help='Default: None. Comma separated list of headers to remove before ' + 'dispatching client request to upstream server.') + parser.add_argument( + '--disable-http-proxy', + action='store_true', + default=DEFAULT_DISABLE_HTTP_PROXY, + help='Default: False. Whether to disable proxy.HttpProxyPlugin.') + parser.add_argument( + '--enable-devtools', + action='store_true', + default=DEFAULT_ENABLE_DEVTOOLS, + help='Default: False. Enables integration with Chrome Devtool Frontend.' + ) + parser.add_argument( + '--enable-events', + action='store_true', + default=DEFAULT_ENABLE_EVENTS, + help='Default: False. Enables core to dispatch lifecycle events. ' + 'Plugins can be used to subscribe for core events.' + ) + parser.add_argument( + '--enable-static-server', + action='store_true', + default=DEFAULT_ENABLE_STATIC_SERVER, + help='Default: False. Enable inbuilt static file server. ' + 'Optionally, also use --static-server-dir to serve static content ' + 'from custom directory. By default, static file server serves ' + 'from public folder.' + ) + parser.add_argument( + '--enable-web-server', + action='store_true', + default=DEFAULT_ENABLE_WEB_SERVER, + help='Default: False. Whether to enable proxy.HttpWebServerPlugin.') + parser.add_argument('--hostname', + type=str, + default=str(DEFAULT_IPV6_HOSTNAME), + help='Default: ::1. Server IP address.') + parser.add_argument( + '--key-file', + type=str, + default=DEFAULT_KEY_FILE, + help='Default: None. Server key file to enable end-to-end TLS encryption with clients. ' + 'If used, must also pass --cert-file.' + ) + parser.add_argument( + '--log-level', + type=str, + default=DEFAULT_LOG_LEVEL, + help='Valid options: DEBUG, INFO (default), WARNING, ERROR, CRITICAL. ' + 'Both upper and lowercase values are allowed. ' + 'You may also simply use the leading character e.g. --log-level d') + parser.add_argument('--log-file', type=str, default=DEFAULT_LOG_FILE, + help='Default: sys.stdout. Log file destination.') + parser.add_argument('--log-format', type=str, default=DEFAULT_LOG_FORMAT, + help='Log format for Python logger.') + parser.add_argument('--num-workers', type=int, default=DEFAULT_NUM_WORKERS, + help='Defaults to number of CPU cores.') + parser.add_argument( + '--open-file-limit', + type=int, + default=DEFAULT_OPEN_FILE_LIMIT, + help='Default: 1024. Maximum number of files (TCP connections) ' + 'that proxy.py can open concurrently.') + parser.add_argument( + '--pac-file', + type=str, + default=DEFAULT_PAC_FILE, + help='A file (Proxy Auto Configuration) or string to serve when ' + 'the server receives a direct file request. ' + 'Using this option enables proxy.HttpWebServerPlugin.') + parser.add_argument( + '--pac-file-url-path', + type=str, + default=text_(DEFAULT_PAC_FILE_URL_PATH), + help='Default: %s. Web server path to serve the PAC file.' % + text_(DEFAULT_PAC_FILE_URL_PATH)) + parser.add_argument( + '--pid-file', + type=str, + default=DEFAULT_PID_FILE, + help='Default: None. Save parent process ID to a file.') + parser.add_argument( + '--plugins', + type=str, + default=DEFAULT_PLUGINS, + help='Comma separated plugins') + parser.add_argument('--port', type=int, default=DEFAULT_PORT, + help='Default: 8899. Server port.') + parser.add_argument( + '--server-recvbuf-size', + type=int, + default=DEFAULT_SERVER_RECVBUF_SIZE, + help='Default: 1 MB. Maximum amount of data received from the ' + 'server in a single recv() operation. Bump this ' + 'value for faster downloads at the expense of ' + 'increased RAM.') + parser.add_argument( + '--static-server-dir', + type=str, + default=DEFAULT_STATIC_SERVER_DIR, + help='Default: "public" folder in directory where proxy.py is placed. ' + 'This option is only applicable when static server is also enabled. ' + 'See --enable-static-server.' + ) + parser.add_argument( + '--threadless', + action='store_true', + default=DEFAULT_THREADLESS, + help='Default: False. When disabled a new thread is spawned ' + 'to handle each client connection.' + ) + parser.add_argument( + '--timeout', + type=int, + default=DEFAULT_TIMEOUT, + help='Default: ' + str(DEFAULT_TIMEOUT) + + '. Number of seconds after which ' + 'an inactive connection must be dropped. Inactivity is defined by no ' + 'data sent or received by the client.' + ) + parser.add_argument( + '--version', + '-v', + action='store_true', + default=DEFAULT_VERSION, + help='Prints proxy.py version.') + return parser + + +class Flags: + """Contains all input flags and inferred input parameters.""" + + ROOT_DATA_DIR_NAME = '.proxy.py' + GENERATED_CERTS_DIR_NAME = 'certificates' + + def __init__( + self, + auth_code: Optional[bytes] = DEFAULT_BASIC_AUTH, + server_recvbuf_size: int = DEFAULT_SERVER_RECVBUF_SIZE, + client_recvbuf_size: int = DEFAULT_CLIENT_RECVBUF_SIZE, + pac_file: Optional[str] = DEFAULT_PAC_FILE, + pac_file_url_path: Optional[bytes] = DEFAULT_PAC_FILE_URL_PATH, + plugins: Optional[Dict[bytes, List[type]]] = None, + disable_headers: Optional[List[bytes]] = None, + certfile: Optional[str] = None, + keyfile: Optional[str] = None, + ca_cert_dir: Optional[str] = None, + ca_key_file: Optional[str] = None, + ca_cert_file: Optional[str] = None, + ca_signing_key_file: Optional[str] = None, + num_workers: int = 0, + hostname: Union[ipaddress.IPv4Address, + ipaddress.IPv6Address] = DEFAULT_IPV6_HOSTNAME, + port: int = DEFAULT_PORT, + backlog: int = DEFAULT_BACKLOG, + static_server_dir: str = DEFAULT_STATIC_SERVER_DIR, + enable_static_server: bool = DEFAULT_ENABLE_STATIC_SERVER, + devtools_event_queue: Optional[DictQueueType] = None, + devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH, + timeout: int = DEFAULT_TIMEOUT, + threadless: bool = DEFAULT_THREADLESS, + enable_events: bool = DEFAULT_ENABLE_EVENTS) -> None: + self.threadless = threadless + self.timeout = timeout + self.auth_code = auth_code + self.server_recvbuf_size = server_recvbuf_size + self.client_recvbuf_size = client_recvbuf_size + self.pac_file = pac_file + self.pac_file_url_path = pac_file_url_path + if plugins is None: + plugins = {} + self.plugins: Dict[bytes, List[type]] = plugins + if disable_headers is None: + disable_headers = DEFAULT_DISABLE_HEADERS + self.disable_headers = disable_headers + self.certfile: Optional[str] = certfile + self.keyfile: Optional[str] = keyfile + self.ca_key_file: Optional[str] = ca_key_file + self.ca_cert_file: Optional[str] = ca_cert_file + self.ca_signing_key_file: Optional[str] = ca_signing_key_file + self.num_workers: int = num_workers if num_workers > 0 else multiprocessing.cpu_count() + self.hostname: Union[ipaddress.IPv4Address, + ipaddress.IPv6Address] = hostname + self.family: socket.AddressFamily = socket.AF_INET6 if hostname.version == 6 else socket.AF_INET + self.port: int = port + self.backlog: int = backlog + + self.enable_static_server: bool = enable_static_server + self.static_server_dir: str = static_server_dir + + self.devtools_event_queue: Optional[DictQueueType] = devtools_event_queue + self.devtools_ws_path: bytes = devtools_ws_path + + self.enable_events: bool = enable_events + + self.proxy_py_data_dir = os.path.join( + str(pathlib.Path.home()), self.ROOT_DATA_DIR_NAME) + os.makedirs(self.proxy_py_data_dir, exist_ok=True) + + self.ca_cert_dir: Optional[str] = ca_cert_dir + if self.ca_cert_dir is None: + self.ca_cert_dir = os.path.join( + self.proxy_py_data_dir, self.GENERATED_CERTS_DIR_NAME) + os.makedirs(self.ca_cert_dir, exist_ok=True) + + def tls_interception_enabled(self) -> bool: + return self.ca_key_file is not None and \ + self.ca_cert_dir is not None and \ + self.ca_signing_key_file is not None and \ + self.ca_cert_file is not None + + def encryption_enabled(self) -> bool: + return self.keyfile is not None and \ + self.certfile is not None diff --git a/proxy/common/types.py b/proxy/common/types.py new file mode 100644 index 0000000000..514523af40 --- /dev/null +++ b/proxy/common/types.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import queue + +from typing import TYPE_CHECKING, Dict, Any +from typing_extensions import Protocol + + +if TYPE_CHECKING: + DictQueueType = queue.Queue[Dict[str, Any]] # pragma: no cover +else: + DictQueueType = queue.Queue + + +class HasFileno(Protocol): + def fileno(self) -> int: + ... # pragma: no cover diff --git a/proxy/common/utils.py b/proxy/common/utils.py new file mode 100644 index 0000000000..61a3b9cc39 --- /dev/null +++ b/proxy/common/utils.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import contextlib +import functools +import ipaddress +import socket + +from types import TracebackType +from typing import Optional, Dict, Any, List, Tuple, Type, Callable + +from .constants import HTTP_1_1, COLON, WHITESPACE, CRLF + + +def text_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: + """Utility to ensure text-like usability. + + If s is of type bytes or int, return s.decode(encoding, errors), + otherwise return s as it is.""" + if isinstance(s, int): + return str(s) + if isinstance(s, bytes): + return s.decode(encoding, errors) + return s + + +def bytes_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: + """Utility to ensure binary-like usability. + + If s is type str or int, return s.encode(encoding, errors), + otherwise return s as it is.""" + if isinstance(s, int): + s = str(s) + if isinstance(s, str): + return s.encode(encoding, errors) + return s + + +def build_http_request(method: bytes, url: bytes, + protocol_version: bytes = HTTP_1_1, + headers: Optional[Dict[bytes, bytes]] = None, + body: Optional[bytes] = None) -> bytes: + """Build and returns a HTTP request packet.""" + if headers is None: + headers = {} + return build_http_pkt( + [method, url, protocol_version], headers, body) + + +def build_http_response(status_code: int, + protocol_version: bytes = HTTP_1_1, + reason: Optional[bytes] = None, + headers: Optional[Dict[bytes, bytes]] = None, + body: Optional[bytes] = None) -> bytes: + """Build and returns a HTTP response packet.""" + line = [protocol_version, bytes_(status_code)] + if reason: + line.append(reason) + if headers is None: + headers = {} + has_content_length = False + has_transfer_encoding = False + for k in headers: + if k.lower() == b'content-length': + has_content_length = True + if k.lower() == b'transfer-encoding': + has_transfer_encoding = True + if body is not None and \ + not has_transfer_encoding and \ + not has_content_length: + headers[b'Content-Length'] = bytes_(len(body)) + return build_http_pkt(line, headers, body) + + +def build_http_header(k: bytes, v: bytes) -> bytes: + """Build and return a HTTP header line for use in raw packet.""" + return k + COLON + WHITESPACE + v + + +def build_http_pkt(line: List[bytes], + headers: Optional[Dict[bytes, bytes]] = None, + body: Optional[bytes] = None) -> bytes: + """Build and returns a HTTP request or response packet.""" + req = WHITESPACE.join(line) + CRLF + if headers is not None: + for k in headers: + req += build_http_header(k, headers[k]) + CRLF + req += CRLF + if body: + req += body + return req + + +def build_websocket_handshake_request( + key: bytes, + method: bytes = b'GET', + url: bytes = b'/') -> bytes: + """ + Build and returns a Websocket handshake request packet. + + :param key: Sec-WebSocket-Key header value. + :param method: HTTP method. + :param url: Websocket request path. + """ + return build_http_request( + method, url, + headers={ + b'Connection': b'upgrade', + b'Upgrade': b'websocket', + b'Sec-WebSocket-Key': key, + b'Sec-WebSocket-Version': b'13', + } + ) + + +def build_websocket_handshake_response(accept: bytes) -> bytes: + """ + Build and returns a Websocket handshake response packet. + + :param accept: Sec-WebSocket-Accept header value + """ + return build_http_response( + 101, reason=b'Switching Protocols', + headers={ + b'Upgrade': b'websocket', + b'Connection': b'Upgrade', + b'Sec-WebSocket-Accept': accept + } + ) + + +def find_http_line(raw: bytes) -> Tuple[Optional[bytes], bytes]: + """Find and returns first line ending in CRLF along with following buffer. + + If no ending CRLF is found, line is None.""" + pos = raw.find(CRLF) + if pos == -1: + return None, raw + line = raw[:pos] + rest = raw[pos + len(CRLF):] + return line, rest + + +def new_socket_connection(addr: Tuple[str, int]) -> socket.socket: + conn = None + try: + ip = ipaddress.ip_address(addr[0]) + if ip.version == 4: + conn = socket.socket( + socket.AF_INET, socket.SOCK_STREAM, 0) + conn.connect(addr) + else: + conn = socket.socket( + socket.AF_INET6, socket.SOCK_STREAM, 0) + conn.connect((addr[0], addr[1], 0, 0)) + except ValueError: + pass # does not appear to be an IPv4 or IPv6 address + + if conn is not None: + return conn + + # try to establish dual stack IPv4/IPv6 connection. + return socket.create_connection(addr) + + +class socket_connection(contextlib.ContextDecorator): + """Same as new_socket_connection but as a context manager and decorator.""" + + def __init__(self, addr: Tuple[str, int]): + self.addr: Tuple[str, int] = addr + self.conn: Optional[socket.socket] = None + super().__init__() + + def __enter__(self) -> socket.socket: + self.conn = new_socket_connection(self.addr) + return self.conn + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType]) -> bool: + if self.conn: + self.conn.close() + return False + + def __call__(self, func: Callable[..., Any] + ) -> Callable[[socket.socket], Any]: + @functools.wraps(func) + def decorated(*args: Any, **kwargs: Any) -> Any: + with self as conn: + return func(conn, *args, **kwargs) + return decorated diff --git a/proxy/common/version.py b/proxy/common/version.py new file mode 100644 index 0000000000..e17bd02046 --- /dev/null +++ b/proxy/common/version.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +VERSION = (2, 0, 0) +__version__ = '.'.join(map(str, VERSION[0:3])) diff --git a/proxy/core/__init__.py b/proxy/core/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/proxy/core/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor.py new file mode 100644 index 0000000000..c5d26a3e1b --- /dev/null +++ b/proxy/core/acceptor.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging +import multiprocessing +import selectors +import socket +import threading +# import time +from multiprocessing import connection +from multiprocessing.reduction import send_handle, recv_handle +from typing import List, Optional, Type, Tuple + +from .threadless import ThreadlessWork, Threadless +from .event import EventQueue, EventDispatcher, eventNames +from ..common.flags import Flags + +logger = logging.getLogger(__name__) + + +class AcceptorPool: + """AcceptorPool. + + Pre-spawns worker processes to utilize all cores available on the system. Server socket connection is + dispatched over a pipe to workers. Each worker accepts incoming client request and spawns a + separate thread to handle the client request. + """ + + def __init__(self, flags: Flags, work_klass: Type[ThreadlessWork]) -> None: + self.flags = flags + self.running: bool = False + self.socket: Optional[socket.socket] = None + self.acceptors: List[Acceptor] = [] + self.work_queues: List[connection.Connection] = [] + self.work_klass = work_klass + + self.event_queue: Optional[EventQueue] = None + self.event_dispatcher: Optional[EventDispatcher] = None + self.event_dispatcher_thread: Optional[threading.Thread] = None + self.event_dispatcher_shutdown: Optional[threading.Event] = None + if self.flags.enable_events: + self.event_queue = EventQueue() + + def listen(self) -> None: + self.socket = socket.socket(self.flags.family, socket.SOCK_STREAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind((str(self.flags.hostname), self.flags.port)) + self.socket.listen(self.flags.backlog) + self.socket.setblocking(False) + logger.info( + 'Listening on %s:%d' % + (self.flags.hostname, self.flags.port)) + + def start_workers(self) -> None: + """Start worker processes.""" + for acceptor_id in range(self.flags.num_workers): + work_queue = multiprocessing.Pipe() + acceptor = Acceptor( + idd=acceptor_id, + work_queue=work_queue[1], + flags=self.flags, + work_klass=self.work_klass, + event_queue=self.event_queue + ) + acceptor.start() + logger.debug('Started acceptor process %d', acceptor.pid) + self.acceptors.append(acceptor) + self.work_queues.append(work_queue[0]) + logger.info('Started %d workers' % self.flags.num_workers) + + def start_event_dispatcher(self) -> None: + self.event_dispatcher_shutdown = threading.Event() + assert self.event_dispatcher_shutdown + assert self.event_queue + self.event_dispatcher = EventDispatcher( + shutdown=self.event_dispatcher_shutdown, + event_queue=self.event_queue + ) + self.event_dispatcher_thread = threading.Thread( + target=self.event_dispatcher.run + ) + self.event_dispatcher_thread.start() + logger.debug('Thread ID: %d', self.event_dispatcher_thread.ident) + + def shutdown(self) -> None: + logger.info('Shutting down %d workers' % self.flags.num_workers) + if self.flags.enable_events: + assert self.event_dispatcher_shutdown + assert self.event_dispatcher_thread + self.event_dispatcher_shutdown.set() + self.event_dispatcher_thread.join() + logger.debug( + 'Shutdown of global event dispatcher thread %d successful', + self.event_dispatcher_thread.ident) + for acceptor in self.acceptors: + acceptor.join() + logger.debug('Acceptors shutdown') + + def setup(self) -> None: + """Listen on port, setup workers and pass server socket to workers.""" + self.running = True + self.listen() + if self.flags.enable_events: + self.start_event_dispatcher() + self.start_workers() + + # Send server socket to all acceptor processes. + assert self.socket is not None + for index in range(self.flags.num_workers): + send_handle( + self.work_queues[index], + self.socket.fileno(), + self.acceptors[index].pid + ) + self.work_queues[index].close() + self.socket.close() + + +class Acceptor(multiprocessing.Process): + """Socket client acceptor. + + Accepts client connection over received server socket handle and + starts a new work thread. + """ + + lock = multiprocessing.Lock() + + def __init__( + self, + idd: int, + work_queue: connection.Connection, + flags: Flags, + work_klass: Type[ThreadlessWork], + event_queue: Optional[EventQueue] = None) -> None: + super().__init__() + self.idd = idd + self.work_queue: connection.Connection = work_queue + self.flags = flags + self.work_klass = work_klass + self.event_queue = event_queue + + self.running = False + self.selector: Optional[selectors.DefaultSelector] = None + self.sock: Optional[socket.socket] = None + self.threadless_process: Optional[multiprocessing.Process] = None + self.threadless_client_queue: Optional[connection.Connection] = None + + def start_threadless_process(self) -> None: + pipe = multiprocessing.Pipe() + self.threadless_client_queue = pipe[0] + self.threadless_process = Threadless( + client_queue=pipe[1], + flags=self.flags, + work_klass=self.work_klass, + event_queue=self.event_queue + ) + self.threadless_process.start() + logger.debug('Started process %d', self.threadless_process.pid) + + def shutdown_threadless_process(self) -> None: + assert self.threadless_process and self.threadless_client_queue + logger.debug('Stopped process %d', self.threadless_process.pid) + self.threadless_process.join() + self.threadless_client_queue.close() + + def start_work(self, conn: socket.socket, addr: Tuple[str, int]) -> None: + if self.flags.threadless and \ + self.threadless_client_queue and \ + self.threadless_process: + self.threadless_client_queue.send(addr) + send_handle( + self.threadless_client_queue, + conn.fileno(), + self.threadless_process.pid + ) + conn.close() + else: + work = self.work_klass( + fileno=conn.fileno(), + addr=addr, + flags=self.flags, + event_queue=self.event_queue + ) + work_thread = threading.Thread(target=work.run) + work.publish_event( + event_name=eventNames.WORK_STARTED, + event_payload={'fileno': conn.fileno(), 'addr': addr}, + publisher_id=self.__class__.__name__ + ) + work_thread.start() + + def run_once(self) -> None: + assert self.selector and self.sock + with self.lock: + events = self.selector.select(timeout=1) + if len(events) == 0: + return + conn, addr = self.sock.accept() + # now = time.time() + # fileno: int = conn.fileno() + self.start_work(conn, addr) + # logger.info('Work started for fd %d in %f seconds', fileno, time.time() - now) + + def run(self) -> None: + self.running = True + self.selector = selectors.DefaultSelector() + fileno = recv_handle(self.work_queue) + self.work_queue.close() + self.sock = socket.fromfd( + fileno, + family=self.flags.family, + type=socket.SOCK_STREAM + ) + try: + self.selector.register(self.sock, selectors.EVENT_READ) + if self.flags.threadless: + self.start_threadless_process() + while self.running: + self.run_once() + except KeyboardInterrupt: + pass + finally: + self.selector.unregister(self.sock) + if self.flags.threadless: + self.shutdown_threadless_process() + self.sock.close() + self.running = False diff --git a/proxy/core/connection.py b/proxy/core/connection.py new file mode 100644 index 0000000000..d7084c0cda --- /dev/null +++ b/proxy/core/connection.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +import ssl +import logging +from abc import ABC, abstractmethod +from typing import NamedTuple, Optional, Union, Tuple + +from ..common.constants import DEFAULT_BUFFER_SIZE +from ..common.utils import new_socket_connection + +logger = logging.getLogger(__name__) + + +TcpConnectionTypes = NamedTuple('TcpConnectionTypes', [ + ('SERVER', int), + ('CLIENT', int), +]) +tcpConnectionTypes = TcpConnectionTypes(1, 2) + + +class TcpConnectionUninitializedException(Exception): + pass + + +class TcpConnection(ABC): + """TCP server/client connection abstraction. + + Main motivation of this class is to provide a buffer management + when reading and writing into the socket. + + Implement the connection property abstract method to return + a socket connection object.""" + + def __init__(self, tag: int): + self.buffer: bytes = b'' + self.closed: bool = False + self.tag: str = 'server' if tag == tcpConnectionTypes.SERVER else 'client' + + @property + @abstractmethod + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + """Must return the socket connection to use in this class.""" + raise TcpConnectionUninitializedException() # pragma: no cover + + def send(self, data: bytes) -> int: + """Users must handle BrokenPipeError exceptions""" + return self.connection.send(data) + + def recv(self, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Optional[bytes]: + """Users must handle socket.error exceptions""" + data: bytes = self.connection.recv(buffer_size) + if len(data) == 0: + return None + logger.debug( + 'received %d bytes from %s' % + (len(data), self.tag)) + # logger.info(data) + return data + + def close(self) -> bool: + if not self.closed: + self.connection.close() + self.closed = True + return self.closed + + def buffer_size(self) -> int: + return len(self.buffer) + + def has_buffer(self) -> bool: + return self.buffer_size() > 0 + + def queue(self, data: bytes) -> int: + self.buffer += data + return len(data) + + def flush(self) -> int: + """Users must handle BrokenPipeError exceptions""" + if self.buffer_size() == 0: + return 0 + sent: int = self.send(self.buffer) + # logger.info(self.buffer[:sent]) + self.buffer = self.buffer[sent:] + logger.debug('flushed %d bytes to %s' % (sent, self.tag)) + return sent + + +class TcpServerConnection(TcpConnection): + """Establishes connection to upstream server.""" + + def __init__(self, host: str, port: int): + super().__init__(tcpConnectionTypes.SERVER) + self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None + self.addr: Tuple[str, int] = (host, int(port)) + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + if self._conn is None: + raise TcpConnectionUninitializedException() + return self._conn + + def connect(self) -> None: + if self._conn is not None: + return + self._conn = new_socket_connection(self.addr) + + +class TcpClientConnection(TcpConnection): + """An accepted client connection request.""" + + def __init__(self, + conn: Union[ssl.SSLSocket, socket.socket], + addr: Tuple[str, int]): + super().__init__(tcpConnectionTypes.CLIENT) + self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = conn + self.addr: Tuple[str, int] = addr + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + if self._conn is None: + raise TcpConnectionUninitializedException() + return self._conn diff --git a/proxy/core/event.py b/proxy/core/event.py new file mode 100644 index 0000000000..b7c4f4716e --- /dev/null +++ b/proxy/core/event.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import queue +import time +import threading +import multiprocessing +import logging + +from typing import Dict, Optional, Any, NamedTuple, List + +from ..common.types import DictQueueType + +logger = logging.getLogger(__name__) + + +EventNames = NamedTuple('EventNames', [ + ('WORK_STARTED', int), + ('WORK_FINISHED', int), + ('SUBSCRIBE', int), + ('UNSUBSCRIBE', int), +]) +eventNames = EventNames(1, 2, 3, 4) + + +class EventQueue: + """Global event queue.""" + + def __init__(self) -> None: + super().__init__() + self.queue = multiprocessing.Manager().Queue() + + def publish( + self, + request_id: str, + event_name: int, + event_payload: Dict[str, Any], + publisher_id: Optional[str] = None + ) -> None: + """Publish an event into the queue. + + 1. Request ID - Globally unique + 2. Process ID - Process ID of event publisher. + This will be process id of acceptor workers. + 3. Thread ID - Thread ID of event publisher. + When --threadless is enabled, this value will be same for all the requests + received by a single acceptor worker. + When --threadless is disabled, this value will be + Thread ID of the thread handling the client request. + 4. Event Timestamp - Time when this event occur + 5. Event Name - One of the defined or custom event name + 6. Event Payload - Optional data associated with the event + 7. Publisher ID (optional) - Optionally, publishing entity unique name / ID + """ + self.queue.put({ + 'request_id': request_id, + 'process_id': os.getpid(), + 'thread_id': threading.get_ident(), + 'event_timestamp': time.time(), + 'event_name': event_name, + 'event_payload': event_payload, + 'publisher_id': publisher_id, + }) + + def subscribe( + self, + sub_id: str, + channel: DictQueueType) -> None: + self.queue.put({ + 'event_name': eventNames.SUBSCRIBE, + 'event_payload': {'sub_id': sub_id, 'channel': channel}, + }) + + +class EventDispatcher: + """Core EventDispatcher. + + Provides: + 1. A dispatcher module which consumes core events and dispatches + them to EventQueueBasePlugin + 2. A publish utility for publishing core events into + global events queue. + + Direct consuming from global events queue outside of dispatcher + module is not-recommended. Python native multiprocessing queue + doesn't provide a fanout functionality which core dispatcher module + implements so that several plugins can consume same published + event at a time. + + When --enable-events is used, a multiprocessing.Queue is created and + attached to global Flags. This queue can then be used for + dispatching an Event dict object into the queue. + + When --enable-events is used, dispatcher module is automatically + started. Dispatcher module also ensures that queue is not full and + doesn't utilize too much memory in case there are no event plugins + enabled. + """ + + def __init__( + self, + shutdown: threading.Event, + event_queue: EventQueue) -> None: + self.shutdown: threading.Event = shutdown + self.event_queue: EventQueue = event_queue + self.subscribers: Dict[str, DictQueueType] = {} + + def run(self) -> None: + try: + while not self.shutdown.is_set(): + try: + ev = self.event_queue.queue.get(timeout=1) + if ev['event_name'] == eventNames.SUBSCRIBE: + self.subscribers[ev['event_payload']['sub_id']] = \ + ev['event_payload']['channel'] + elif ev['event_name'] == eventNames.UNSUBSCRIBE: + del self.subscribers[ev['event_payload']['sub_id']] + else: + # logger.info(ev) + unsub_ids: List[str] = [] + for sub_id in self.subscribers: + try: + self.subscribers[sub_id].put(ev) + except BrokenPipeError: + unsub_ids.append(sub_id) + for sub_id in unsub_ids: + del self.subscribers[sub_id] + except queue.Empty: + pass + except EOFError: + pass + except KeyboardInterrupt: + pass + except Exception as e: + logger.exception('Event dispatcher exception', exc_info=e) diff --git a/proxy/core/threadless.py b/proxy/core/threadless.py new file mode 100644 index 0000000000..a5cb61dfd7 --- /dev/null +++ b/proxy/core/threadless.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import uuid +import socket +import logging +import asyncio +import selectors +import contextlib +import ssl +import multiprocessing +from multiprocessing import connection +from multiprocessing.reduction import recv_handle + +from abc import ABC, abstractmethod +from typing import Dict, Optional, Tuple, List, Union, Generator, Any, Type + +from .event import EventQueue, eventNames + +from ..common.flags import Flags +from ..common.types import HasFileno +from ..common.constants import DEFAULT_TIMEOUT + +logger = logging.getLogger(__name__) + + +class ThreadlessWork(ABC): + """Implement ThreadlessWork to hook into the event loop provided by Threadless process.""" + + def publish_event( + self, + event_name: int, + event_payload: Dict[str, Any], + publisher_id: Optional[str] = None) -> None: + if not self.flags.enable_events: + return + assert self.event_queue + self.event_queue.publish( + self.uid, + event_name, + event_payload, + publisher_id + ) + + def shutdown(self) -> None: + """Must close any opened resources and call super().shutdown().""" + self.publish_event( + event_name=eventNames.WORK_FINISHED, + event_payload={}, + publisher_id=self.__class__.__name__ + ) + + @abstractmethod + def __init__( + self, + fileno: int, + addr: Tuple[str, int], + flags: Optional[Flags], + event_queue: Optional[EventQueue] = None, + uid: Optional[str] = None) -> None: + self.fileno = fileno + self.addr = addr + self.flags = flags if flags else Flags() + + self.event_queue = event_queue + self.uid: str = uid if uid is not None else uuid.uuid4().hex + + @abstractmethod + def initialize(self) -> None: + pass # pragma: no cover + + @abstractmethod + def is_inactive(self) -> bool: + return False # pragma: no cover + + @abstractmethod + def get_events(self) -> Dict[socket.socket, int]: + return {} # pragma: no cover + + @abstractmethod + def handle_events( + self, + readables: List[Union[int, HasFileno]], + writables: List[Union[int, HasFileno]]) -> bool: + """Return True to shutdown work.""" + return False # pragma: no cover + + @abstractmethod + def run(self) -> None: + pass + + +class Threadless(multiprocessing.Process): + """Threadless provides an event loop. Use it by implementing Threadless class. + + When --threadless option is enabled, each Acceptor process also + spawns one Threadless process. And instead of spawning new thread + for each accepted client connection, Acceptor process sends + accepted client connection to Threadless process over a pipe. + + HttpProtocolHandler implements ThreadlessWork class and hooks into the + event loop provided by Threadless. + """ + + def __init__( + self, + client_queue: connection.Connection, + flags: Flags, + work_klass: Type[ThreadlessWork], + event_queue: Optional[EventQueue] = None) -> None: + super().__init__() + self.client_queue = client_queue + self.flags = flags + self.work_klass = work_klass + self.event_queue = event_queue + + self.works: Dict[int, ThreadlessWork] = {} + self.selector: Optional[selectors.DefaultSelector] = None + self.loop: Optional[asyncio.AbstractEventLoop] = None + + @contextlib.contextmanager + def selected_events(self) -> Generator[Tuple[List[Union[int, HasFileno]], + List[Union[int, HasFileno]]], + None, None]: + events: Dict[socket.socket, int] = {} + for work in self.works.values(): + events.update(work.get_events()) + assert self.selector is not None + for fd in events: + self.selector.register(fd, events[fd]) + ev = self.selector.select(timeout=1) + readables = [] + writables = [] + for key, mask in ev: + if mask & selectors.EVENT_READ: + readables.append(key.fileobj) + if mask & selectors.EVENT_WRITE: + writables.append(key.fileobj) + yield (readables, writables) + for fd in events.keys(): + self.selector.unregister(fd) + + async def handle_events( + self, fileno: int, + readables: List[Union[int, HasFileno]], + writables: List[Union[int, HasFileno]]) -> bool: + return self.works[fileno].handle_events(readables, writables) + + # TODO: Use correct future typing annotations + async def wait_for_tasks( + self, tasks: Dict[int, Any]) -> None: + for work_id in tasks: + # TODO: Resolving one handle_events here can block resolution of + # other tasks + try: + teardown = await asyncio.wait_for(tasks[work_id], DEFAULT_TIMEOUT) + if teardown: + self.cleanup(work_id) + except asyncio.TimeoutError: + self.cleanup(work_id) + + def accept_client(self) -> None: + addr = self.client_queue.recv() + fileno = recv_handle(self.client_queue) + self.works[fileno] = self.work_klass( + fileno=fileno, + addr=addr, + flags=self.flags, + event_queue=self.event_queue + ) + self.works[fileno].publish_event( + event_name=eventNames.WORK_STARTED, + event_payload={'fileno': fileno, 'addr': addr}, + publisher_id=self.__class__.__name__ + ) + try: + self.works[fileno].initialize() + except ssl.SSLError as e: + logger.exception('ssl.SSLError', exc_info=e) + self.cleanup(fileno) + + def cleanup_inactive(self) -> None: + inactive_works: List[int] = [] + for work_id in self.works: + if self.works[work_id].is_inactive(): + inactive_works.append(work_id) + for work_id in inactive_works: + self.cleanup(work_id) + + def cleanup(self, work_id: int) -> None: + # TODO: HttpProtocolHandler.shutdown can call flush which may block + self.works[work_id].shutdown() + del self.works[work_id] + os.close(work_id) + + def run_once(self) -> None: + assert self.loop is not None + with self.selected_events() as (readables, writables): + if len(readables) == 0 and len(writables) == 0: + # Remove and shutdown inactive connections + self.cleanup_inactive() + return + # Note that selector from now on is idle, + # until all the logic below completes. + # + # Invoke Threadless.handle_events + # TODO: Only send readable / writables that client originally + # registered. + tasks = {} + for fileno in self.works: + tasks[fileno] = self.loop.create_task( + self.handle_events(fileno, readables, writables)) + # Accepted client connection from Acceptor + if self.client_queue in readables: + self.accept_client() + # Wait for Threadless.handle_events to complete + self.loop.run_until_complete(self.wait_for_tasks(tasks)) + # Remove and shutdown inactive connections + self.cleanup_inactive() + + def run(self) -> None: + try: + self.selector = selectors.DefaultSelector() + self.selector.register(self.client_queue, selectors.EVENT_READ) + self.loop = asyncio.get_event_loop() + while True: + self.run_once() + except KeyboardInterrupt: + pass + finally: + assert self.selector is not None + self.selector.unregister(self.client_queue) + self.client_queue.close() + assert self.loop is not None + self.loop.close() diff --git a/proxy/http/__init__.py b/proxy/http/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/proxy/http/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/http/chunk_parser.py b/proxy/http/chunk_parser.py new file mode 100644 index 0000000000..0350d736a8 --- /dev/null +++ b/proxy/http/chunk_parser.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import NamedTuple, Tuple, List, Optional + +from ..common.utils import bytes_, find_http_line +from ..common.constants import CRLF, DEFAULT_BUFFER_SIZE + + +ChunkParserStates = NamedTuple('ChunkParserStates', [ + ('WAITING_FOR_SIZE', int), + ('WAITING_FOR_DATA', int), + ('COMPLETE', int), +]) +chunkParserStates = ChunkParserStates(1, 2, 3) + + +class ChunkParser: + """HTTP chunked encoding response parser.""" + + def __init__(self) -> None: + self.state = chunkParserStates.WAITING_FOR_SIZE + self.body: bytes = b'' # Parsed chunks + self.chunk: bytes = b'' # Partial chunk received + # Expected size of next following chunk + self.size: Optional[int] = None + + def parse(self, raw: bytes) -> bytes: + more = True if len(raw) > 0 else False + while more and self.state != chunkParserStates.COMPLETE: + more, raw = self.process(raw) + return raw + + def process(self, raw: bytes) -> Tuple[bool, bytes]: + if self.state == chunkParserStates.WAITING_FOR_SIZE: + # Consume prior chunk in buffer + # in case chunk size without CRLF was received + raw = self.chunk + raw + self.chunk = b'' + # Extract following chunk data size + line, raw = find_http_line(raw) + # CRLF not received or Blank line was received. + if line is None or line.strip() == b'': + self.chunk = raw + raw = b'' + else: + self.size = int(line, 16) + self.state = chunkParserStates.WAITING_FOR_DATA + elif self.state == chunkParserStates.WAITING_FOR_DATA: + assert self.size is not None + remaining = self.size - len(self.chunk) + self.chunk += raw[:remaining] + raw = raw[remaining:] + if len(self.chunk) == self.size: + raw = raw[len(CRLF):] + self.body += self.chunk + if self.size == 0: + self.state = chunkParserStates.COMPLETE + else: + self.state = chunkParserStates.WAITING_FOR_SIZE + self.chunk = b'' + self.size = None + return len(raw) > 0, raw + + @staticmethod + def to_chunks(raw: bytes, chunk_size: int = DEFAULT_BUFFER_SIZE) -> bytes: + chunks: List[bytes] = [] + for i in range(0, len(raw), chunk_size): + chunk = raw[i: i + chunk_size] + chunks.append(bytes_('{:x}'.format(len(chunk)))) + chunks.append(chunk) + chunks.append(bytes_('{:x}'.format(0))) + chunks.append(b'') + return CRLF.join(chunks) + CRLF diff --git a/proxy/http/codes.py b/proxy/http/codes.py new file mode 100644 index 0000000000..5a55ab045a --- /dev/null +++ b/proxy/http/codes.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import NamedTuple + + +HttpStatusCodes = NamedTuple('HttpStatusCodes', [ + # 1xx + ('CONTINUE', int), + ('SWITCHING_PROTOCOLS', int), + # 2xx + ('OK', int), + # 3xx + ('MOVED_PERMANENTLY', int), + ('SEE_OTHER', int), + ('TEMPORARY_REDIRECT', int), + ('PERMANENT_REDIRECT', int), + # 4xx + ('BAD_REQUEST', int), + ('UNAUTHORIZED', int), + ('FORBIDDEN', int), + ('NOT_FOUND', int), + ('PROXY_AUTH_REQUIRED', int), + ('REQUEST_TIMEOUT', int), + ('I_AM_A_TEAPOT', int), + # 5xx + ('INTERNAL_SERVER_ERROR', int), + ('NOT_IMPLEMENTED', int), + ('BAD_GATEWAY', int), + ('GATEWAY_TIMEOUT', int), + ('NETWORK_READ_TIMEOUT_ERROR', int), + ('NETWORK_CONNECT_TIMEOUT_ERROR', int), +]) +httpStatusCodes = HttpStatusCodes( + 100, 101, + 200, + 301, 303, 307, 308, + 400, 401, 403, 404, 407, 408, 418, + 500, 501, 502, 504, 598, 599 +) diff --git a/proxy/http/devtools.py b/proxy/http/devtools.py new file mode 100644 index 0000000000..8eeb62f32a --- /dev/null +++ b/proxy/http/devtools.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import threading +import queue +import socket +import time +import secrets +import os +import logging +import json +from typing import Optional, Union, List, Tuple, Dict, Any + +from .parser import httpParserStates, httpParserTypes, HttpParser +from .server import HttpWebServerBasePlugin, httpProtocolTypes +from .websocket import WebsocketFrame, websocketOpcodes +from .handler import HttpProtocolHandlerPlugin + +from ..common.constants import COLON, PROXY_PY_START_TIME +from ..common.types import HasFileno, DictQueueType +from ..common.utils import bytes_, text_ + +from ..core.connection import TcpClientConnection + +logger = logging.getLogger(__name__) + + +class DevtoolsWebsocketPlugin(HttpWebServerBasePlugin): + """DevtoolsWebsocketPlugin handles Devtools Frontend websocket requests. + + For every connected Devtools Frontend instance, a dispatcher thread is + started which drains the global Devtools protocol events queue. + + Dispatcher thread is terminated when Devtools Frontend disconnects.""" + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.event_dispatcher_thread: Optional[threading.Thread] = None + self.event_dispatcher_shutdown: Optional[threading.Event] = None + + def start_dispatcher(self) -> None: + self.event_dispatcher_shutdown = threading.Event() + assert self.flags.devtools_event_queue is not None + self.event_dispatcher_thread = threading.Thread( + target=DevtoolsWebsocketPlugin.event_dispatcher, + args=(self.event_dispatcher_shutdown, + self.flags.devtools_event_queue, + self.client)) + self.event_dispatcher_thread.start() + + def stop_dispatcher(self) -> None: + assert self.event_dispatcher_shutdown is not None + assert self.event_dispatcher_thread is not None + self.event_dispatcher_shutdown.set() + self.event_dispatcher_thread.join() + logger.debug('Event dispatcher shutdown') + + @staticmethod + def event_dispatcher( + shutdown: threading.Event, + devtools_event_queue: DictQueueType, + client: TcpClientConnection) -> None: + while not shutdown.is_set(): + try: + ev = devtools_event_queue.get(timeout=1) + frame = WebsocketFrame() + frame.fin = True + frame.opcode = websocketOpcodes.TEXT_FRAME + frame.data = bytes_(json.dumps(ev)) + logger.debug(ev) + client.queue(frame.build()) + except queue.Empty: + pass + except Exception as e: + logger.exception('Event dispatcher exception', exc_info=e) + break + except KeyboardInterrupt: + break + + def routes(self) -> List[Tuple[int, bytes]]: + return [ + (httpProtocolTypes.WEBSOCKET, self.flags.devtools_ws_path) + ] + + def handle_request(self, request: HttpParser) -> None: + pass + + def on_websocket_open(self) -> None: + self.start_dispatcher() + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + if frame.data: + message = json.loads(frame.data) + self.handle_message(message) + else: + logger.debug('No data found in frame') + + def on_websocket_close(self) -> None: + self.stop_dispatcher() + + def handle_message(self, message: Dict[str, Any]) -> None: + frame = WebsocketFrame() + frame.fin = True + frame.opcode = websocketOpcodes.TEXT_FRAME + + if message['method'] in ( + 'Page.canScreencast', + 'Network.canEmulateNetworkConditions', + 'Emulation.canEmulate' + ): + data = json.dumps({ + 'id': message['id'], + 'result': False + }) + elif message['method'] == 'Page.getResourceTree': + data = json.dumps({ + 'id': message['id'], + 'result': { + 'frameTree': { + 'frame': { + 'id': 1, + 'url': 'http://proxypy', + 'mimeType': 'other', + }, + 'childFrames': [], + 'resources': [] + } + } + }) + elif message['method'] == 'Network.getResponseBody': + logger.debug('received request method Network.getResponseBody') + data = json.dumps({ + 'id': message['id'], + 'result': { + 'body': '', + 'base64Encoded': False, + } + }) + else: + data = json.dumps({ + 'id': message['id'], + 'result': {}, + }) + + frame.data = bytes_(data) + self.client.queue(frame.build()) + + +class DevtoolsProtocolPlugin(HttpProtocolHandlerPlugin): + """ + DevtoolsProtocolPlugin taps into core `HttpProtocolHandler` + events and converts them into Devtools Protocol json messages. + + A DevtoolsProtocolPlugin instance is created per request. + Per request devtool events are queued into a global multiprocessing queue. + """ + + frame_id = secrets.token_hex(8) + loader_id = secrets.token_hex(8) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.id: str = f'{ os.getpid() }-{ threading.get_ident() }-{ time.time() }' + self.response = HttpParser(httpParserTypes.RESPONSE_PARSER) + + def get_descriptors( + self) -> Tuple[List[socket.socket], List[socket.socket]]: + return [], [] + + def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: + return False + + def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: + return False + + def on_client_data(self, raw: bytes) -> Optional[bytes]: + return raw + + def on_request_complete(self) -> Union[socket.socket, bool]: + if not self.request.has_upstream_server() and \ + self.request.path == self.config.devtools_ws_path: + return False + + # Handle devtool frontend websocket upgrade + if self.config.devtools_event_queue: + self.config.devtools_event_queue.put({ + 'method': 'Network.requestWillBeSent', + 'params': self.request_will_be_sent(), + }) + return False + + def on_response_chunk(self, chunk: bytes) -> bytes: + if not self.request.has_upstream_server() and \ + self.request.path == self.config.devtools_ws_path: + return chunk + + if self.config.devtools_event_queue: + self.response.parse(chunk) + if self.response.state >= httpParserStates.HEADERS_COMPLETE: + self.config.devtools_event_queue.put({ + 'method': 'Network.responseReceived', + 'params': self.response_received(), + }) + if self.response.state >= httpParserStates.RCVING_BODY: + self.config.devtools_event_queue.put({ + 'method': 'Network.dataReceived', + 'params': self.data_received(chunk) + }) + if self.response.state == httpParserStates.COMPLETE: + self.config.devtools_event_queue.put({ + 'method': 'Network.loadingFinished', + 'params': self.loading_finished() + }) + return chunk + + def on_client_connection_close(self) -> None: + pass + + def request_will_be_sent(self) -> Dict[str, Any]: + now = time.time() + return { + 'requestId': self.id, + 'loaderId': self.loader_id, + 'documentURL': 'http://proxy-py', + 'request': { + 'url': text_( + self.request.path + if self.request.has_upstream_server() else + b'http://' + bytes_(str(self.config.hostname)) + + COLON + bytes_(self.config.port) + self.request.path + ), + 'urlFragment': '', + 'method': text_(self.request.method), + 'headers': {text_(v[0]): text_(v[1]) for v in self.request.headers.values()}, + 'initialPriority': 'High', + 'mixedContentType': 'none', + 'postData': None if self.request.method != 'POST' + else text_(self.request.body) + }, + 'timestamp': now - PROXY_PY_START_TIME, + 'wallTime': now, + 'initiator': { + 'type': 'other' + }, + 'type': text_(self.request.header(b'content-type')) + if self.request.has_header(b'content-type') + else 'Other', + 'frameId': self.frame_id, + 'hasUserGesture': False + } + + def response_received(self) -> Dict[str, Any]: + return { + 'requestId': self.id, + 'frameId': self.frame_id, + 'loaderId': self.loader_id, + 'timestamp': time.time(), + 'type': text_(self.response.header(b'content-type')) + if self.response.has_header(b'content-type') + else 'Other', + 'response': { + 'url': '', + 'status': '', + 'statusText': '', + 'headers': '', + 'headersText': '', + 'mimeType': '', + 'connectionReused': True, + 'connectionId': '', + 'encodedDataLength': '', + 'fromDiskCache': False, + 'fromServiceWorker': False, + 'timing': { + 'requestTime': '', + 'proxyStart': -1, + 'proxyEnd': -1, + 'dnsStart': -1, + 'dnsEnd': -1, + 'connectStart': -1, + 'connectEnd': -1, + 'sslStart': -1, + 'sslEnd': -1, + 'workerStart': -1, + 'workerReady': -1, + 'sendStart': 0, + 'sendEnd': 0, + 'receiveHeadersEnd': 0, + }, + 'requestHeaders': '', + 'remoteIPAddress': '', + 'remotePort': '', + } + } + + def data_received(self, chunk: bytes) -> Dict[str, Any]: + return { + 'requestId': self.id, + 'timestamp': time.time(), + 'dataLength': len(chunk), + 'encodedDataLength': len(chunk), + } + + def loading_finished(self) -> Dict[str, Any]: + return { + 'requestId': self.id, + 'timestamp': time.time(), + 'encodedDataLength': self.response.total_size + } diff --git a/proxy/http/exception.py b/proxy/http/exception.py new file mode 100644 index 0000000000..e519b2983c --- /dev/null +++ b/proxy/http/exception.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional, Dict + +from .parser import HttpParser +from .codes import httpStatusCodes + +from ..common.constants import PROXY_AGENT_HEADER_VALUE, PROXY_AGENT_HEADER_KEY +from ..common.utils import build_http_response + + +class HttpProtocolException(Exception): + """Top level HttpProtocolException exception class. + + All exceptions raised during execution of Http request lifecycle MUST + inherit HttpProtocolException base class. Implement response() method + to optionally return custom response to client.""" + + def response(self, request: HttpParser) -> Optional[bytes]: + return None # pragma: no cover + + +class HttpRequestRejected(HttpProtocolException): + """Generic exception that can be used to reject the client requests. + + Connections can either be dropped/closed or optionally an + HTTP status code can be returned.""" + + def __init__(self, + status_code: Optional[int] = None, + reason: Optional[bytes] = None, + headers: Optional[Dict[bytes, bytes]] = None, + body: Optional[bytes] = None): + self.status_code: Optional[int] = status_code + self.reason: Optional[bytes] = reason + self.headers: Optional[Dict[bytes, bytes]] = headers + self.body: Optional[bytes] = body + + def response(self, _request: HttpParser) -> Optional[bytes]: + if self.status_code: + return build_http_response( + status_code=self.status_code, + reason=self.reason, + headers=self.headers, + body=self.body + ) + return None + + +class ProxyConnectionFailed(HttpProtocolException): + """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server.""" + + RESPONSE_PKT = build_http_response( + httpStatusCodes.BAD_GATEWAY, + reason=b'Bad Gateway', + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close' + }, + body=b'Bad Gateway' + ) + + def __init__(self, host: str, port: int, reason: str): + self.host: str = host + self.port: int = port + self.reason: str = reason + + def response(self, _request: HttpParser) -> bytes: + return self.RESPONSE_PKT + + +class ProxyAuthenticationFailed(HttpProtocolException): + """Exception raised when Http Proxy auth is enabled and + incoming request doesn't present necessary credentials.""" + + RESPONSE_PKT = build_http_response( + httpStatusCodes.PROXY_AUTH_REQUIRED, + reason=b'Proxy Authentication Required', + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + b'Proxy-Authenticate': b'Basic', + b'Connection': b'close', + }, + body=b'Proxy Authentication Required') + + def response(self, _request: HttpParser) -> bytes: + return self.RESPONSE_PKT diff --git a/proxy/http/handler.py b/proxy/http/handler.py new file mode 100644 index 0000000000..b954893987 --- /dev/null +++ b/proxy/http/handler.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +import selectors +import ssl +import time +import contextlib +import errno +import logging +from abc import ABC, abstractmethod +from typing import Tuple, List, Union, Optional, Generator, Dict + +from .parser import HttpParser, httpParserStates, httpParserTypes +from .exception import HttpProtocolException + +from ..common.flags import Flags +from ..common.types import HasFileno +from ..core.threadless import ThreadlessWork +from ..core.event import EventQueue +from ..core.connection import TcpClientConnection + +logger = logging.getLogger(__name__) + + +class HttpProtocolHandlerPlugin(ABC): + """Base HttpProtocolHandler Plugin class. + + NOTE: This is an internal plugin and in most cases only useful for core contributors. + If you are looking for proxy server plugins see ``. + + Implements various lifecycle events for an accepted client connection. + Following events are of interest: + + 1. Client Connection Accepted + A new plugin instance is created per accepted client connection. + Add your logic within __init__ constructor for any per connection setup. + 2. Client Request Chunk Received + on_client_data is called for every chunk of data sent by the client. + 3. Client Request Complete + on_request_complete is called once client request has completed. + 4. Server Response Chunk Received + on_response_chunk is called for every chunk received from the server. + 5. Client Connection Closed + Add your logic within `on_client_connection_close` for any per connection teardown. + """ + + def __init__( + self, + config: Flags, + client: TcpClientConnection, + request: HttpParser, + event_queue: EventQueue): + self.config: Flags = config + self.client: TcpClientConnection = client + self.request: HttpParser = request + self.event_queue = event_queue + super().__init__() + + def name(self) -> str: + """A unique name for your plugin. + + Defaults to name of the class. This helps plugin developers to directly + access a specific plugin by its name.""" + return self.__class__.__name__ + + @abstractmethod + def get_descriptors( + self) -> Tuple[List[socket.socket], List[socket.socket]]: + return [], [] # pragma: no cover + + @abstractmethod + def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: + pass # pragma: no cover + + @abstractmethod + def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: + pass # pragma: no cover + + @abstractmethod + def on_client_data(self, raw: bytes) -> Optional[bytes]: + return raw # pragma: no cover + + @abstractmethod + def on_request_complete(self) -> Union[socket.socket, bool]: + """Called right after client request parser has reached COMPLETE state.""" + pass # pragma: no cover + + @abstractmethod + def on_response_chunk(self, chunk: bytes) -> bytes: + """Handle data chunks as received from the server. + + Return optionally modified chunk to return back to client.""" + return chunk # pragma: no cover + + @abstractmethod + def on_client_connection_close(self) -> None: + pass # pragma: no cover + + +class HttpProtocolHandler(ThreadlessWork): + """HTTP, HTTPS, HTTP2, WebSockets protocol handler. + + Accepts `Client` connection object and manages HttpProtocolHandlerPlugin invocations. + """ + + def __init__(self, fileno: int, addr: Tuple[str, int], + flags: Optional[Flags] = None, + event_queue: Optional[EventQueue] = None, + uid: Optional[str] = None): + super().__init__(fileno, addr, flags, event_queue, uid) + + self.start_time: float = time.time() + self.last_activity: float = self.start_time + self.request: HttpParser = HttpParser(httpParserTypes.REQUEST_PARSER) + self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) + self.selector = selectors.DefaultSelector() + self.client: TcpClientConnection = TcpClientConnection( + self.fromfd(self.fileno), self.addr + ) + self.plugins: Dict[str, HttpProtocolHandlerPlugin] = {} + + def initialize(self) -> None: + """Optionally upgrades connection to HTTPS, set conn in non-blocking mode and initializes plugins.""" + conn = self.optionally_wrap_socket(self.client.connection) + conn.setblocking(False) + if self.flags.encryption_enabled(): + self.client = TcpClientConnection(conn=conn, addr=self.addr) + if b'HttpProtocolHandlerPlugin' in self.flags.plugins: + for klass in self.flags.plugins[b'HttpProtocolHandlerPlugin']: + instance = klass( + self.flags, + self.client, + self.request, + self.event_queue) + self.plugins[instance.name()] = instance + logger.debug('Handling connection %r' % self.client.connection) + + def is_inactive(self) -> bool: + if not self.client.has_buffer() and \ + self.connection_inactive_for() > self.flags.timeout: + return True + return False + + def get_events(self) -> Dict[socket.socket, int]: + events: Dict[socket.socket, int] = { + self.client.connection: selectors.EVENT_READ + } + if self.client.has_buffer(): + events[self.client.connection] |= selectors.EVENT_WRITE + + # HttpProtocolHandlerPlugin.get_descriptors + for plugin in self.plugins.values(): + plugin_read_desc, plugin_write_desc = plugin.get_descriptors() + for r in plugin_read_desc: + if r not in events: + events[r] = selectors.EVENT_READ + else: + events[r] |= selectors.EVENT_READ + for w in plugin_write_desc: + if w not in events: + events[w] = selectors.EVENT_WRITE + else: + events[w] |= selectors.EVENT_WRITE + + return events + + def handle_events( + self, + readables: List[Union[int, HasFileno]], + writables: List[Union[int, HasFileno]]) -> bool: + """Returns True if proxy must teardown.""" + # Flush buffer for ready to write sockets + teardown = self.handle_writables(writables) + if teardown: + return True + + # Invoke plugin.write_to_descriptors + for plugin in self.plugins.values(): + teardown = plugin.write_to_descriptors(writables) + if teardown: + return True + + # Read from ready to read sockets + teardown = self.handle_readables(readables) + if teardown: + return True + + # Invoke plugin.read_from_descriptors + for plugin in self.plugins.values(): + teardown = plugin.read_from_descriptors(readables) + if teardown: + return True + + return False + + def shutdown(self) -> None: + try: + # Flush pending buffer if any + self.flush() + + # Invoke plugin.on_client_connection_close + for plugin in self.plugins.values(): + plugin.on_client_connection_close() + + logger.debug( + 'Closing client connection %r ' + 'at address %r with pending client buffer size %d bytes' % + (self.client.connection, self.client.addr, self.client.buffer_size())) + + conn = self.client.connection + # Unwrap if wrapped before shutdown. + if self.flags.encryption_enabled() and \ + isinstance(self.client.connection, ssl.SSLSocket): + conn = self.client.connection.unwrap() + conn.shutdown(socket.SHUT_WR) + logger.debug('Client connection shutdown successful') + except OSError: + pass + finally: + self.client.connection.close() + logger.debug('Client connection closed') + super().shutdown() + + def fromfd(self, fileno: int) -> socket.socket: + conn = socket.fromfd( + fileno, family=socket.AF_INET if self.flags.hostname.version == 4 else socket.AF_INET6, + type=socket.SOCK_STREAM) + return conn + + def optionally_wrap_socket( + self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]: + """Attempts to wrap accepted client connection using provided certificates. + + Shutdown and closes client connection upon error. + """ + if self.flags.encryption_enabled(): + ctx = ssl.create_default_context( + ssl.Purpose.CLIENT_AUTH) + ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ctx.verify_mode = ssl.CERT_NONE + assert self.flags.keyfile and self.flags.certfile + ctx.load_cert_chain( + certfile=self.flags.certfile, + keyfile=self.flags.keyfile) + conn = ctx.wrap_socket(conn, server_side=True) + return conn + + def connection_inactive_for(self) -> float: + return time.time() - self.last_activity + + def flush(self) -> None: + if not self.client.has_buffer(): + return + try: + self.selector.register( + self.client.connection, + selectors.EVENT_WRITE) + while self.client.has_buffer(): + ev: List[Tuple[selectors.SelectorKey, int] + ] = self.selector.select(timeout=1) + if len(ev) == 0: + continue + self.client.flush() + except BrokenPipeError: + pass + finally: + self.selector.unregister(self.client.connection) + + def handle_writables(self, writables: List[Union[int, HasFileno]]) -> bool: + if self.client.buffer_size() > 0 and self.client.connection in writables: + logger.debug('Client is ready for writes, flushing buffer') + self.last_activity = time.time() + + # Invoke plugin.on_response_chunk + chunk = self.client.buffer + for plugin in self.plugins.values(): + chunk = plugin.on_response_chunk(chunk) + if chunk is None: + break + + try: + self.client.flush() + except OSError: + logger.error('OSError when flushing buffer to client') + return True + except BrokenPipeError: + logger.error( + 'BrokenPipeError when flushing buffer for client') + return True + return False + + def handle_readables(self, readables: List[Union[int, HasFileno]]) -> bool: + if self.client.connection in readables: + logger.debug('Client is ready for reads, reading') + self.last_activity = time.time() + try: + client_data = self.client.recv(self.flags.client_recvbuf_size) + except ssl.SSLWantReadError: # Try again later + logger.warning( + 'SSLWantReadError encountered while reading from client, will retry ...') + return False + except socket.error as e: + if e.errno == errno.ECONNRESET: + logger.warning('%r' % e) + else: + logger.exception( + 'Exception while receiving from %s connection %r with reason %r' % + (self.client.tag, self.client.connection, e)) + return True + + if not client_data: + logger.debug('Client closed connection, tearing down...') + self.client.closed = True + return True + + try: + # HttpProtocolHandlerPlugin.on_client_data + # Can raise HttpProtocolException to teardown the connection + plugin_index = 0 + plugins = list(self.plugins.values()) + while plugin_index < len(plugins) and client_data: + client_data = plugins[plugin_index].on_client_data( + client_data) + if client_data is None: + break + plugin_index += 1 + + # Don't parse request any further after 1st request has completed. + # This specially does happen for pipeline requests. + # Plugins can utilize on_client_data for such cases and + # apply custom logic to handle request data sent after 1st + # valid request. + if client_data and self.request.state != httpParserStates.COMPLETE: + # Parse http request + self.request.parse(client_data) + if self.request.state == httpParserStates.COMPLETE: + # Invoke plugin.on_request_complete + for plugin in self.plugins.values(): + upgraded_sock = plugin.on_request_complete() + if isinstance(upgraded_sock, ssl.SSLSocket): + logger.debug( + 'Updated client conn to %s', upgraded_sock) + self.client._conn = upgraded_sock + for plugin_ in self.plugins.values(): + if plugin_ != plugin: + plugin_.client._conn = upgraded_sock + elif isinstance(upgraded_sock, bool) and upgraded_sock is True: + return True + except HttpProtocolException as e: + logger.debug( + 'HttpProtocolException type raised') + response = e.response(self.request) + if response: + self.client.queue(response) + return True + return False + + @contextlib.contextmanager + def selected_events(self) -> \ + Generator[Tuple[List[Union[int, HasFileno]], + List[Union[int, HasFileno]]], + None, None]: + events = self.get_events() + for fd in events: + self.selector.register(fd, events[fd]) + ev = self.selector.select(timeout=1) + readables = [] + writables = [] + for key, mask in ev: + if mask & selectors.EVENT_READ: + readables.append(key.fileobj) + if mask & selectors.EVENT_WRITE: + writables.append(key.fileobj) + yield (readables, writables) + for fd in events.keys(): + self.selector.unregister(fd) + + def run_once(self) -> bool: + with self.selected_events() as (readables, writables): + teardown = self.handle_events(readables, writables) + if teardown: + return True + return False + + def run(self) -> None: + try: + self.initialize() + while True: + # Teardown if client buffer is empty and connection is inactive + if self.is_inactive(): + logger.debug( + 'Client buffer is empty and maximum inactivity has reached ' + 'between client and server connection, tearing down...') + break + teardown = self.run_once() + if teardown: + break + except KeyboardInterrupt: # pragma: no cover + pass + except ssl.SSLError as e: + logger.exception('ssl.SSLError', exc_info=e) + except Exception as e: + logger.exception( + 'Exception while handling connection %r' % + self.client.connection, exc_info=e) + finally: + self.shutdown() diff --git a/proxy/http/methods.py b/proxy/http/methods.py new file mode 100644 index 0000000000..2ec2184c0d --- /dev/null +++ b/proxy/http/methods.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import NamedTuple + + +HttpMethods = NamedTuple('HttpMethods', [ + ('GET', bytes), + ('HEAD', bytes), + ('POST', bytes), + ('PUT', bytes), + ('DELETE', bytes), + ('CONNECT', bytes), + ('OPTIONS', bytes), + ('TRACE', bytes), + ('PATCH', bytes), +]) +httpMethods = HttpMethods( + b'GET', + b'HEAD', + b'POST', + b'PUT', + b'DELETE', + b'CONNECT', + b'OPTIONS', + b'TRACE', + b'PATCH', +) diff --git a/proxy/http/parser.py b/proxy/http/parser.py new file mode 100644 index 0000000000..efcac32c68 --- /dev/null +++ b/proxy/http/parser.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from urllib import parse as urlparse +from typing import TypeVar, NamedTuple, Optional, Dict, Type, Tuple, List + +from .methods import httpMethods +from .chunk_parser import ChunkParser, chunkParserStates + +from ..common.constants import DEFAULT_DISABLE_HEADERS, COLON, CRLF, WHITESPACE, HTTP_1_1 +from ..common.utils import build_http_request, find_http_line, text_ + + +HttpParserStates = NamedTuple('HttpParserStates', [ + ('INITIALIZED', int), + ('LINE_RCVD', int), + ('RCVING_HEADERS', int), + ('HEADERS_COMPLETE', int), + ('RCVING_BODY', int), + ('COMPLETE', int), +]) +httpParserStates = HttpParserStates(1, 2, 3, 4, 5, 6) + +HttpParserTypes = NamedTuple('HttpParserTypes', [ + ('REQUEST_PARSER', int), + ('RESPONSE_PARSER', int), +]) +httpParserTypes = HttpParserTypes(1, 2) + + +T = TypeVar('T', bound='HttpParser') + + +class HttpParser: + """HTTP request/response parser.""" + + def __init__(self, parser_type: int) -> None: + self.type: int = parser_type + self.state: int = httpParserStates.INITIALIZED + + # Raw bytes as passed to parse(raw) method and its total size + self.bytes: bytes = b'' + self.total_size: int = 0 + + # Buffer to hold unprocessed bytes + self.buffer: bytes = b'' + + self.headers: Dict[bytes, Tuple[bytes, bytes]] = dict() + self.body: Optional[bytes] = None + + self.method: Optional[bytes] = None + self.url: Optional[urlparse.SplitResultBytes] = None + self.code: Optional[bytes] = None + self.reason: Optional[bytes] = None + self.version: Optional[bytes] = None + + self.chunk_parser: Optional[ChunkParser] = None + + # This cleans up developer APIs as Python urlparse.urlsplit behaves differently + # for incoming proxy request and incoming web request. Web request is the one + # which is broken. + self.host: Optional[bytes] = None + self.port: Optional[int] = None + self.path: Optional[bytes] = None + + @classmethod + def request(cls: Type[T], raw: bytes) -> T: + parser = cls(httpParserTypes.REQUEST_PARSER) + parser.parse(raw) + return parser + + @classmethod + def response(cls: Type[T], raw: bytes) -> T: + parser = cls(httpParserTypes.RESPONSE_PARSER) + parser.parse(raw) + return parser + + def header(self, key: bytes) -> bytes: + if key.lower() not in self.headers: + raise KeyError('%s not found in headers', text_(key)) + return self.headers[key.lower()][1] + + def has_header(self, key: bytes) -> bool: + return key.lower() in self.headers + + def add_header(self, key: bytes, value: bytes) -> None: + self.headers[key.lower()] = (key, value) + + def add_headers(self, headers: List[Tuple[bytes, bytes]]) -> None: + for (key, value) in headers: + self.add_header(key, value) + + def del_header(self, header: bytes) -> None: + if header.lower() in self.headers: + del self.headers[header.lower()] + + def del_headers(self, headers: List[bytes]) -> None: + for key in headers: + self.del_header(key.lower()) + + def set_url(self, url: bytes) -> None: + self.url = urlparse.urlsplit(url) + self.set_line_attributes() + + def set_line_attributes(self) -> None: + if self.type == httpParserTypes.REQUEST_PARSER: + if self.method == httpMethods.CONNECT and self.url: + u = urlparse.urlsplit(b'//' + self.url.path) + self.host, self.port = u.hostname, u.port + elif self.url: + self.host, self.port = self.url.hostname, self.url.port \ + if self.url.port else 80 + else: + raise KeyError('Invalid request\n%s' % self.bytes) + self.path = self.build_url() + + def is_chunked_encoded(self) -> bool: + return b'transfer-encoding' in self.headers and \ + self.headers[b'transfer-encoding'][1].lower() == b'chunked' + + def parse(self, raw: bytes) -> None: + """Parses Http request out of raw bytes. + + Check HttpParser state after parse has successfully returned.""" + self.bytes += raw + self.total_size += len(raw) + + # Prepend past buffer + raw = self.buffer + raw + self.buffer = b'' + + more = True if len(raw) > 0 else False + while more and self.state != httpParserStates.COMPLETE: + if self.state in ( + httpParserStates.HEADERS_COMPLETE, + httpParserStates.RCVING_BODY): + if b'content-length' in self.headers: + self.state = httpParserStates.RCVING_BODY + if self.body is None: + self.body = b'' + total_size = int(self.header(b'content-length')) + received_size = len(self.body) + self.body += raw[:total_size - received_size] + if self.body and \ + len(self.body) == int(self.header(b'content-length')): + self.state = httpParserStates.COMPLETE + more, raw = len(raw) > 0, raw[total_size - received_size:] + elif self.is_chunked_encoded(): + if not self.chunk_parser: + self.chunk_parser = ChunkParser() + raw = self.chunk_parser.parse(raw) + if self.chunk_parser.state == chunkParserStates.COMPLETE: + self.body = self.chunk_parser.body + self.state = httpParserStates.COMPLETE + more = False + else: + more, raw = self.process(raw) + self.buffer = raw + + def process(self, raw: bytes) -> Tuple[bool, bytes]: + """Returns False when no CRLF could be found in received bytes.""" + line, raw = find_http_line(raw) + if line is None: + return False, raw + + if self.state == httpParserStates.INITIALIZED: + self.process_line(line) + self.state = httpParserStates.LINE_RCVD + elif self.state in (httpParserStates.LINE_RCVD, httpParserStates.RCVING_HEADERS): + if self.state == httpParserStates.LINE_RCVD: + # LINE_RCVD state is equivalent to RCVING_HEADERS + self.state = httpParserStates.RCVING_HEADERS + if line.strip() == b'': # Blank line received. + self.state = httpParserStates.HEADERS_COMPLETE + else: + self.process_header(line) + + # When connect request is received without a following host header + # See + # `TestHttpParser.test_connect_request_without_host_header_request_parse` + # for details + if self.state == httpParserStates.LINE_RCVD and \ + self.type == httpParserTypes.RESPONSE_PARSER and \ + raw == CRLF: + self.state = httpParserStates.COMPLETE + # When raw request has ended with \r\n\r\n and no more http headers are expected + # See `TestHttpParser.test_request_parse_without_content_length` and + # `TestHttpParser.test_response_parse_without_content_length` for details + elif self.state == httpParserStates.HEADERS_COMPLETE and \ + self.type == httpParserTypes.REQUEST_PARSER and \ + self.method != httpMethods.POST and \ + self.bytes.endswith(CRLF * 2): + self.state = httpParserStates.COMPLETE + elif self.state == httpParserStates.HEADERS_COMPLETE and \ + self.type == httpParserTypes.REQUEST_PARSER and \ + self.method == httpMethods.POST and \ + not self.is_chunked_encoded() and \ + (b'content-length' not in self.headers or + (b'content-length' in self.headers and + int(self.headers[b'content-length'][1]) == 0)) and \ + self.bytes.endswith(CRLF * 2): + self.state = httpParserStates.COMPLETE + + return len(raw) > 0, raw + + def process_line(self, raw: bytes) -> None: + line = raw.split(WHITESPACE) + if self.type == httpParserTypes.REQUEST_PARSER: + self.method = line[0].upper() + self.set_url(line[1]) + self.version = line[2] + else: + self.version = line[0] + self.code = line[1] + self.reason = WHITESPACE.join(line[2:]) + + def process_header(self, raw: bytes) -> None: + parts = raw.split(COLON) + key = parts[0].strip() + value = COLON.join(parts[1:]).strip() + self.add_headers([(key, value)]) + + def build_url(self) -> bytes: + if not self.url: + return b'/None' + url = self.url.path + if url == b'': + url = b'/' + if not self.url.query == b'': + url += b'?' + self.url.query + if not self.url.fragment == b'': + url += b'#' + self.url.fragment + return url + + def build(self, disable_headers: Optional[List[bytes]] = None) -> bytes: + assert self.method and self.version and self.path + if disable_headers is None: + disable_headers = DEFAULT_DISABLE_HEADERS + body: Optional[bytes] = ChunkParser.to_chunks(self.body) \ + if self.is_chunked_encoded() and self.body else \ + self.body + return build_http_request( + self.method, self.path, self.version, + headers={} if not self.headers else {self.headers[k][0]: self.headers[k][1] for k in self.headers if + k.lower() not in disable_headers}, + body=body + ) + + def has_upstream_server(self) -> bool: + """Host field SHOULD be None for incoming local WebServer requests.""" + return True if self.host is not None else False + + def is_http_1_1_keep_alive(self) -> bool: + return self.version == HTTP_1_1 and \ + (not self.has_header(b'Connection') or + self.header(b'Connection').lower() == b'keep-alive') diff --git a/proxy/http/proxy.py b/proxy/http/proxy.py new file mode 100644 index 0000000000..8df0505913 --- /dev/null +++ b/proxy/http/proxy.py @@ -0,0 +1,458 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import threading +import subprocess +import os +import ssl +import socket +import time +import errno +import logging +from abc import ABC, abstractmethod +from typing import Optional, List, Union, Dict, cast, Any, Tuple + +from .handler import HttpProtocolHandlerPlugin +from .exception import HttpProtocolException, ProxyConnectionFailed, ProxyAuthenticationFailed +from .codes import httpStatusCodes +from .parser import HttpParser, httpParserStates, httpParserTypes +from .methods import httpMethods + +from ..common.types import HasFileno +from ..common.flags import Flags +from ..common.constants import PROXY_AGENT_HEADER_VALUE +from ..common.utils import build_http_response, text_ + +from ..core.connection import TcpClientConnection, TcpServerConnection, TcpConnectionUninitializedException + +logger = logging.getLogger(__name__) + + +class HttpProxyBasePlugin(ABC): + """Base HttpProxyPlugin Plugin class. + + Implement various lifecycle event methods to customize behavior.""" + + def __init__( + self, + config: Flags, + client: TcpClientConnection): + self.config = config # pragma: no cover + self.client = client # pragma: no cover + + def name(self) -> str: + """A unique name for your plugin. + + Defaults to name of the class. This helps plugin developers to directly + access a specific plugin by its name.""" + return self.__class__.__name__ # pragma: no cover + + @abstractmethod + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + """Handler called just before Proxy upstream connection is established. + + Return optionally modified request object. + Raise HttpRequestRejected or HttpProtocolException directly to drop the connection.""" + return request # pragma: no cover + + @abstractmethod + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + """Handler called before dispatching client request to upstream. + + Note: For pipelined (keep-alive) connections, this handler can be + called multiple times, for each request sent to upstream. + + Note: If TLS interception is enabled, this handler can + be called multiple times if client exchanges multiple + requests over same SSL session. + + Return optionally modified request object to dispatch to upstream. + Return None to drop the request data, e.g. in case a response has already been queued. + Raise HttpRequestRejected or HttpProtocolException directly to + teardown the connection with client. + """ + return request # pragma: no cover + + @abstractmethod + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + """Handler called right after receiving raw response from upstream server. + + For HTTPS connections, chunk will be encrypted unless + TLS interception is also enabled.""" + return chunk # pragma: no cover + + @abstractmethod + def on_upstream_connection_close(self) -> None: + """Handler called right after upstream connection has been closed.""" + pass # pragma: no cover + + +class HttpProxyPlugin(HttpProtocolHandlerPlugin): + """HttpProtocolHandler plugin which implements HttpProxy specifications.""" + + PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = build_http_response( + httpStatusCodes.OK, + reason=b'Connection established' + ) + + # Used to synchronize with other HttpProxyPlugin instances while + # generating certificates + lock = threading.Lock() + + def __init__( + self, + *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.start_time: float = time.time() + self.server: Optional[TcpServerConnection] = None + self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) + self.pipeline_request: Optional[HttpParser] = None + self.pipeline_response: Optional[HttpParser] = None + + self.plugins: Dict[str, HttpProxyBasePlugin] = {} + if b'HttpProxyBasePlugin' in self.config.plugins: + for klass in self.config.plugins[b'HttpProxyBasePlugin']: + instance = klass(self.config, self.client) + self.plugins[instance.name()] = instance + + def get_descriptors( + self) -> Tuple[List[socket.socket], List[socket.socket]]: + if not self.request.has_upstream_server(): + return [], [] + + r: List[socket.socket] = [] + w: List[socket.socket] = [] + if self.server and not self.server.closed and self.server.connection: + r.append(self.server.connection) + if self.server and not self.server.closed and \ + self.server.has_buffer() and self.server.connection: + w.append(self.server.connection) + return r, w + + def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: + if self.request.has_upstream_server() and \ + self.server and not self.server.closed and \ + self.server.has_buffer() and \ + self.server.connection in w: + logger.debug('Server is write ready, flushing buffer') + try: + self.server.flush() + except OSError: + logger.error('OSError when flushing buffer to server') + return True + except BrokenPipeError: + logger.error( + 'BrokenPipeError when flushing buffer for server') + return True + return False + + def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: + if self.request.has_upstream_server( + ) and self.server and not self.server.closed and self.server.connection in r: + logger.debug('Server is ready for reads, reading...') + try: + raw = self.server.recv(self.config.server_recvbuf_size) + except ssl.SSLWantReadError: # Try again later + # logger.warning('SSLWantReadError encountered while reading from server, will retry ...') + return False + except socket.error as e: + if e.errno == errno.ECONNRESET: + logger.warning('Connection reset by upstream: %r' % e) + else: + logger.exception( + 'Exception while receiving from %s connection %r with reason %r' % + (self.server.tag, self.server.connection, e)) + return True + + if not raw: + logger.debug('Server closed connection, tearing down...') + return True + + for plugin in self.plugins.values(): + raw = plugin.handle_upstream_chunk(raw) + + # parse incoming response packet + # only for non-https requests and when + # tls interception is enabled + if self.request.method != httpMethods.CONNECT: + # See https://github.com/abhinavsingh/proxy.py/issues/127 for why + # currently response parsing is disabled when TLS interception is enabled. + # + # or self.config.tls_interception_enabled(): + if self.response.state == httpParserStates.COMPLETE: + if self.pipeline_response is None: + self.pipeline_response = HttpParser( + httpParserTypes.RESPONSE_PARSER) + self.pipeline_response.parse(raw) + if self.pipeline_response.state == httpParserStates.COMPLETE: + self.pipeline_response = None + else: + self.response.parse(raw) + else: + self.response.total_size += len(raw) + # queue raw data for client + self.client.queue(raw) + return False + + def access_log(self) -> None: + server_host, server_port = self.server.addr if self.server else ( + None, None) + connection_time_ms = (time.time() - self.start_time) * 1000 + if self.request.method == b'CONNECT': + logger.info( + '%s:%s - %s %s:%s - %s bytes - %.2f ms' % + (self.client.addr[0], + self.client.addr[1], + text_(self.request.method), + text_(server_host), + text_(server_port), + self.response.total_size, + connection_time_ms)) + elif self.request.method: + logger.info( + '%s:%s - %s %s:%s%s - %s %s - %s bytes - %.2f ms' % + (self.client.addr[0], self.client.addr[1], + text_(self.request.method), + text_(server_host), server_port, + text_(self.request.path), + text_(self.response.code), + text_(self.response.reason), + self.response.total_size, + connection_time_ms)) + + def on_client_connection_close(self) -> None: + if not self.request.has_upstream_server(): + return + + self.access_log() + + # If server was never initialized, return + if self.server is None: + return + + # Note that, server instance was initialized + # but not necessarily the connection object exists. + # Invoke plugin.on_upstream_connection_close + for plugin in self.plugins.values(): + plugin.on_upstream_connection_close() + + try: + try: + self.server.connection.shutdown(socket.SHUT_WR) + except OSError: + pass + finally: + # TODO: Unwrap if wrapped before close? + self.server.connection.close() + except TcpConnectionUninitializedException: + pass + finally: + logger.debug( + 'Closed server connection with pending server buffer size %d bytes' % + self.server.buffer_size()) + + def on_response_chunk(self, chunk: bytes) -> bytes: + # TODO: Allow to output multiple access_log lines + # for each request over a pipelined HTTP connection (not for HTTPS). + # However, this must also be accompanied by resetting both request + # and response objects. + # + # if not self.request.method == httpMethods.CONNECT and \ + # self.response.state == httpParserStates.COMPLETE: + # self.access_log() + return chunk + + def on_client_data(self, raw: bytes) -> Optional[bytes]: + if not self.request.has_upstream_server(): + return raw + + if self.server and not self.server.closed: + if self.request.state == httpParserStates.COMPLETE and ( + self.request.method != httpMethods.CONNECT or + self.config.tls_interception_enabled()): + if self.pipeline_request is None: + self.pipeline_request = HttpParser( + httpParserTypes.REQUEST_PARSER) + self.pipeline_request.parse(raw) + if self.pipeline_request.state == httpParserStates.COMPLETE: + for plugin in self.plugins.values(): + assert self.pipeline_request is not None + r = plugin.handle_client_request(self.pipeline_request) + if r is None: + return None + self.pipeline_request = r + assert self.pipeline_request is not None + self.server.queue(self.pipeline_request.build()) + self.pipeline_request = None + else: + self.server.queue(raw) + return None + else: + return raw + + @staticmethod + def generated_cert_file_path(ca_cert_dir: str, host: str) -> str: + return os.path.join(ca_cert_dir, '%s.pem' % host) + + def generate_upstream_certificate( + self, _certificate: Optional[Dict[str, Any]]) -> str: + if not (self.config.ca_cert_dir and self.config.ca_signing_key_file and + self.config.ca_cert_file and self.config.ca_key_file): + raise HttpProtocolException( + f'For certificate generation all the following flags are mandatory: ' + f'--ca-cert-file:{ self.config.ca_cert_file }, ' + f'--ca-key-file:{ self.config.ca_key_file }, ' + f'--ca-signing-key-file:{ self.config.ca_signing_key_file }') + cert_file_path = HttpProxyPlugin.generated_cert_file_path( + self.config.ca_cert_dir, text_(self.request.host)) + with self.lock: + if not os.path.isfile(cert_file_path): + logger.debug('Generating certificates %s', cert_file_path) + # TODO: Parse subject from certificate + # Currently we only set CN= field for generated certificates. + gen_cert = subprocess.Popen( + ['openssl', 'req', '-new', '-key', self.config.ca_signing_key_file, '-subj', + f'/C=/ST=/L=/O=/OU=/CN={ text_(self.request.host) }'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + sign_cert = subprocess.Popen( + ['openssl', 'x509', '-req', '-days', '365', '-CA', self.config.ca_cert_file, '-CAkey', + self.config.ca_key_file, '-set_serial', str(int(time.time())), '-out', cert_file_path], + stdin=gen_cert.stdout, + stderr=subprocess.PIPE) + # TODO: Ensure sign_cert success. + sign_cert.communicate(timeout=10) + return cert_file_path + + def wrap_server(self) -> None: + assert self.server is not None + assert isinstance(self.server.connection, socket.socket) + ctx = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH) + ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + self.server.connection.setblocking(True) + self.server._conn = ctx.wrap_socket( + self.server.connection, + server_hostname=text_(self.request.host)) + self.server.connection.setblocking(False) + + def wrap_client(self) -> None: + assert self.server is not None + assert isinstance(self.server.connection, ssl.SSLSocket) + generated_cert = self.generate_upstream_certificate( + cast(Dict[str, Any], self.server.connection.getpeercert())) + self.client.connection.setblocking(True) + self.client.flush() + self.client._conn = ssl.wrap_socket( + self.client.connection, + server_side=True, + keyfile=self.config.ca_signing_key_file, + certfile=generated_cert) + self.client.connection.setblocking(False) + logger.debug( + 'TLS interception using %s', generated_cert) + + def on_request_complete(self) -> Union[socket.socket, bool]: + if not self.request.has_upstream_server(): + return False + + self.authenticate() + + # Note: can raise HttpRequestRejected exception + # Invoke plugin.before_upstream_connection + do_connect = True + for plugin in self.plugins.values(): + r = plugin.before_upstream_connection(self.request) + if r is None: + do_connect = False + break + self.request = r + + if do_connect: + self.connect_upstream() + + for plugin in self.plugins.values(): + assert self.request is not None + r = plugin.handle_client_request(self.request) + if r is not None: + self.request = r + else: + return False + + if self.request.method == httpMethods.CONNECT: + self.client.queue( + HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) + # If interception is enabled + if self.config.tls_interception_enabled(): + # Perform SSL/TLS handshake with upstream + self.wrap_server() + # Generate certificate and perform handshake with client + try: + # wrap_client also flushes client data before wrapping + # sending to client can raise, handle expected exceptions + self.wrap_client() + except OSError: + logger.error('OSError when wrapping client') + return True + except BrokenPipeError: + logger.error( + 'BrokenPipeError when wrapping client') + return True + # Update all plugin connection reference + for plugin in self.plugins.values(): + plugin.client._conn = self.client.connection + return self.client.connection + elif self.server: + # - proxy-connection header is a mistake, it doesn't seem to be + # officially documented in any specification, drop it. + # - proxy-authorization is of no use for upstream, remove it. + self.request.del_headers( + [b'proxy-authorization', b'proxy-connection']) + # - For HTTP/1.0, connection header defaults to close + # - For HTTP/1.1, connection header defaults to keep-alive + # Respect headers sent by client instead of manipulating + # Connection or Keep-Alive header. However, note that per + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection + # connection headers are meant for communication between client and + # first intercepting proxy. + self.request.add_headers( + [(b'Via', b'1.1 %s' % PROXY_AGENT_HEADER_VALUE)]) + # Disable args.disable_headers before dispatching to upstream + self.server.queue( + self.request.build( + disable_headers=self.config.disable_headers)) + return False + + def authenticate(self) -> None: + if self.config.auth_code: + if b'proxy-authorization' not in self.request.headers or \ + self.request.headers[b'proxy-authorization'][1] != self.config.auth_code: + raise ProxyAuthenticationFailed() + + def connect_upstream(self) -> None: + host, port = self.request.host, self.request.port + if host and port: + self.server = TcpServerConnection(text_(host), port) + try: + logger.debug( + 'Connecting to upstream %s:%s' % + (text_(host), port)) + self.server.connect() + self.server.connection.setblocking(False) + logger.debug( + 'Connected to upstream %s:%s' % + (text_(host), port)) + except Exception as e: # TimeoutError, socket.gaierror + self.server.closed = True + raise ProxyConnectionFailed(text_(host), port, repr(e)) from e + else: + logger.exception('Both host and port must exist') + raise HttpProtocolException() diff --git a/proxy/http/server.py b/proxy/http/server.py new file mode 100644 index 0000000000..9fe37d6bc6 --- /dev/null +++ b/proxy/http/server.py @@ -0,0 +1,309 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import time +import logging +import os +import mimetypes +import socket +from abc import ABC, abstractmethod +from typing import List, Tuple, Optional, NamedTuple, Dict, Union, Any + +from .exception import HttpProtocolException +from .websocket import WebsocketFrame, websocketOpcodes +from .codes import httpStatusCodes +from .parser import HttpParser, httpParserStates, httpParserTypes +from .handler import HttpProtocolHandlerPlugin + +from ..common.utils import bytes_, text_, build_http_response, build_websocket_handshake_response +from ..common.flags import Flags +from ..common.constants import PROXY_AGENT_HEADER_VALUE +from ..common.types import HasFileno +from ..core.connection import TcpClientConnection +from ..core.event import EventQueue + +logger = logging.getLogger(__name__) + + +HttpProtocolTypes = NamedTuple('HttpProtocolTypes', [ + ('HTTP', int), + ('HTTPS', int), + ('WEBSOCKET', int), +]) +httpProtocolTypes = HttpProtocolTypes(1, 2, 3) + + +class HttpWebServerBasePlugin(ABC): + """Web Server Plugin for routing of requests.""" + + def __init__( + self, + flags: Flags, + client: TcpClientConnection, + event_queue: EventQueue): + self.flags = flags + self.client = client + self.event_queue = event_queue + + @abstractmethod + def routes(self) -> List[Tuple[int, bytes]]: + """Return List(protocol, path) that this plugin handles.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def handle_request(self, request: HttpParser) -> None: + """Handle the request and serve response.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def on_websocket_open(self) -> None: + """Called when websocket handshake has finished.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def on_websocket_message(self, frame: WebsocketFrame) -> None: + """Handle websocket frame.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def on_websocket_close(self) -> None: + """Called when websocket connection has been closed.""" + raise NotImplementedError() # pragma: no cover + + +class HttpWebServerPacFilePlugin(HttpWebServerBasePlugin): + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.pac_file_response: Optional[bytes] = None + self.cache_pac_file_response() + + def cache_pac_file_response(self) -> None: + if self.flags.pac_file: + try: + with open(self.flags.pac_file, 'rb') as f: + content = f.read() + except IOError: + content = bytes_(self.flags.pac_file) + self.pac_file_response = build_http_response( + 200, reason=b'OK', headers={ + b'Content-Type': b'application/x-ns-proxy-autoconfig', + }, body=content + ) + + def routes(self) -> List[Tuple[int, bytes]]: + if self.flags.pac_file_url_path: + return [ + (httpProtocolTypes.HTTP, bytes_(self.flags.pac_file_url_path)), + (httpProtocolTypes.HTTPS, bytes_(self.flags.pac_file_url_path)), + ] + return [] # pragma: no cover + + def handle_request(self, request: HttpParser) -> None: + if self.flags.pac_file and self.pac_file_response: + self.client.queue(self.pac_file_response) + + def on_websocket_open(self) -> None: + pass # pragma: no cover + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + pass # pragma: no cover + + def on_websocket_close(self) -> None: + pass # pragma: no cover + + +class HttpWebServerPlugin(HttpProtocolHandlerPlugin): + """HttpProtocolHandler plugin which handles incoming requests to local web server.""" + + DEFAULT_404_RESPONSE = build_http_response( + httpStatusCodes.NOT_FOUND, + reason=b'NOT FOUND', + headers={b'Server': PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close'} + ) + + DEFAULT_501_RESPONSE = build_http_response( + httpStatusCodes.NOT_IMPLEMENTED, + reason=b'NOT IMPLEMENTED', + headers={b'Server': PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close'} + ) + + def __init__( + self, + *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.start_time: float = time.time() + self.pipeline_request: Optional[HttpParser] = None + self.switched_protocol: Optional[int] = None + self.routes: Dict[int, Dict[bytes, HttpWebServerBasePlugin]] = { + httpProtocolTypes.HTTP: {}, + httpProtocolTypes.HTTPS: {}, + httpProtocolTypes.WEBSOCKET: {}, + } + self.route: Optional[HttpWebServerBasePlugin] = None + + if b'HttpWebServerBasePlugin' in self.config.plugins: + for klass in self.config.plugins[b'HttpWebServerBasePlugin']: + instance = klass(self.config, self.client, self.event_queue) + for (protocol, path) in instance.routes(): + self.routes[protocol][path] = instance + + @staticmethod + def read_and_build_static_file_response(path: str) -> bytes: + with open(path, 'rb') as f: + content = f.read() + content_type = mimetypes.guess_type(path)[0] + if content_type is None: + content_type = 'text/plain' + return build_http_response( + httpStatusCodes.OK, + reason=b'OK', + headers={ + b'Content-Type': bytes_(content_type), + b'Connection': b'close', + }, + body=content) + + def serve_file_or_404(self, path: str) -> bool: + """Read and serves a file from disk. + + Queues 404 Not Found for IOError. + Shouldn't this be server error? + """ + try: + self.client.queue( + self.read_and_build_static_file_response(path)) + except IOError: + self.client.queue(self.DEFAULT_404_RESPONSE) + return True + + def try_upgrade(self) -> bool: + if self.request.has_header(b'connection') and \ + self.request.header(b'connection').lower() == b'upgrade': + if self.request.has_header(b'upgrade') and \ + self.request.header(b'upgrade').lower() == b'websocket': + self.client.queue( + build_websocket_handshake_response( + WebsocketFrame.key_to_accept( + self.request.header(b'Sec-WebSocket-Key')))) + self.switched_protocol = httpProtocolTypes.WEBSOCKET + else: + self.client.queue(self.DEFAULT_501_RESPONSE) + return True + return False + + def on_request_complete(self) -> Union[socket.socket, bool]: + if self.request.has_upstream_server(): + return False + + # If a websocket route exists for the path, try upgrade + if self.request.path in self.routes[httpProtocolTypes.WEBSOCKET]: + self.route = self.routes[httpProtocolTypes.WEBSOCKET][self.request.path] + + # Connection upgrade + teardown = self.try_upgrade() + if teardown: + return True + + # For upgraded connections, nothing more to do + if self.switched_protocol: + # Invoke plugin.on_websocket_open + self.route.on_websocket_open() + return False + + # Routing for Http(s) requests + protocol = httpProtocolTypes.HTTPS \ + if self.config.encryption_enabled() else \ + httpProtocolTypes.HTTP + for r in self.routes[protocol]: + if r == self.request.path: + self.route = self.routes[protocol][r] + self.route.handle_request(self.request) + return False + + # No-route found, try static serving if enabled + if self.config.enable_static_server: + path = text_(self.request.path).split('?')[0] + if os.path.isfile(self.config.static_server_dir + path): + return self.serve_file_or_404( + self.config.static_server_dir + path) + + # Catch all unhandled web server requests, return 404 + self.client.queue(self.DEFAULT_404_RESPONSE) + return True + + def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: + pass + + def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: + pass + + def on_client_data(self, raw: bytes) -> Optional[bytes]: + if self.switched_protocol == httpProtocolTypes.WEBSOCKET: + remaining = raw + frame = WebsocketFrame() + while remaining != b'': + # TODO: Teardown if invalid protocol exception + remaining = frame.parse(remaining) + for r in self.routes[httpProtocolTypes.WEBSOCKET]: + if r == self.request.path: + route = self.routes[httpProtocolTypes.WEBSOCKET][r] + if frame.opcode == websocketOpcodes.CONNECTION_CLOSE: + logger.warning( + 'Client sent connection close packet') + raise HttpProtocolException() + else: + route.on_websocket_message(frame) + frame.reset() + return None + # If 1st valid request was completed and it's a HTTP/1.1 keep-alive + # And only if we have a route, parse pipeline requests + elif self.request.state == httpParserStates.COMPLETE and \ + self.request.is_http_1_1_keep_alive() and \ + self.route is not None: + if self.pipeline_request is None: + self.pipeline_request = HttpParser( + httpParserTypes.REQUEST_PARSER) + self.pipeline_request.parse(raw) + if self.pipeline_request.state == httpParserStates.COMPLETE: + self.route.handle_request(self.pipeline_request) + if not self.pipeline_request.is_http_1_1_keep_alive(): + logger.error( + 'Pipelined request is not keep-alive, will teardown request...') + raise HttpProtocolException() + self.pipeline_request = None + return raw + + def on_response_chunk(self, chunk: bytes) -> bytes: + return chunk + + def on_client_connection_close(self) -> None: + if self.request.has_upstream_server(): + return + if self.switched_protocol: + # Invoke plugin.on_websocket_close + for r in self.routes[httpProtocolTypes.WEBSOCKET]: + if r == self.request.path: + self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_close() + self.access_log() + + def access_log(self) -> None: + logger.info( + '%s:%s - %s %s - %.2f ms' % + (self.client.addr[0], + self.client.addr[1], + text_(self.request.method), + text_(self.request.path), + (time.time() - self.start_time) * 1000)) + + def get_descriptors( + self) -> Tuple[List[socket.socket], List[socket.socket]]: + return [], [] diff --git a/proxy/http/websocket.py b/proxy/http/websocket.py new file mode 100644 index 0000000000..9aa8594583 --- /dev/null +++ b/proxy/http/websocket.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import hashlib +import base64 +import selectors +import struct +import socket +import secrets +import ssl +import ipaddress +import logging +import io + +from typing import TypeVar, Type, Optional, NamedTuple, Union, Callable + +from .parser import httpParserTypes, HttpParser + +from ..common.constants import DEFAULT_BUFFER_SIZE +from ..common.utils import new_socket_connection, build_websocket_handshake_request +from ..core.connection import tcpConnectionTypes, TcpConnection + + +WebsocketOpcodes = NamedTuple('WebsocketOpcodes', [ + ('CONTINUATION_FRAME', int), + ('TEXT_FRAME', int), + ('BINARY_FRAME', int), + ('CONNECTION_CLOSE', int), + ('PING', int), + ('PONG', int), +]) +websocketOpcodes = WebsocketOpcodes(0x0, 0x1, 0x2, 0x8, 0x9, 0xA) + + +V = TypeVar('V', bound='WebsocketFrame') + +logger = logging.getLogger(__name__) + + +class WebsocketFrame: + """Websocket frames parser and constructor.""" + + GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + + def __init__(self) -> None: + self.fin: bool = False + self.rsv1: bool = False + self.rsv2: bool = False + self.rsv3: bool = False + self.opcode: int = 0 + self.masked: bool = False + self.payload_length: Optional[int] = None + self.mask: Optional[bytes] = None + self.data: Optional[bytes] = None + + @classmethod + def text(cls: Type[V], data: bytes) -> bytes: + frame = cls() + frame.fin = True + frame.opcode = websocketOpcodes.TEXT_FRAME + frame.data = data + return frame.build() + + def reset(self) -> None: + self.fin = False + self.rsv1 = False + self.rsv2 = False + self.rsv3 = False + self.opcode = 0 + self.masked = False + self.payload_length = None + self.mask = None + self.data = None + + def parse_fin_and_rsv(self, byte: int) -> None: + self.fin = bool(byte & 1 << 7) + self.rsv1 = bool(byte & 1 << 6) + self.rsv2 = bool(byte & 1 << 5) + self.rsv3 = bool(byte & 1 << 4) + self.opcode = byte & 0b00001111 + + def parse_mask_and_payload(self, byte: int) -> None: + self.masked = bool(byte & 0b10000000) + self.payload_length = byte & 0b01111111 + + def build(self) -> bytes: + if self.payload_length is None and self.data: + self.payload_length = len(self.data) + raw = io.BytesIO() + raw.write( + struct.pack( + '!B', + (1 << 7 if self.fin else 0) | + (1 << 6 if self.rsv1 else 0) | + (1 << 5 if self.rsv2 else 0) | + (1 << 4 if self.rsv3 else 0) | + self.opcode + )) + assert self.payload_length is not None + if self.payload_length < 126: + raw.write( + struct.pack( + '!B', + (1 << 7 if self.masked else 0) | self.payload_length + ) + ) + elif self.payload_length < 1 << 16: + raw.write( + struct.pack( + '!BH', + (1 << 7 if self.masked else 0) | 126, + self.payload_length + ) + ) + elif self.payload_length < 1 << 64: + raw.write( + struct.pack( + '!BHQ', + (1 << 7 if self.masked else 0) | 127, + self.payload_length + ) + ) + else: + raise ValueError(f'Invalid payload_length { self.payload_length },' + f'maximum allowed { 1 << 64 }') + if self.masked and self.data: + mask = secrets.token_bytes(4) if self.mask is None else self.mask + raw.write(mask) + raw.write(self.apply_mask(self.data, mask)) + elif self.data: + raw.write(self.data) + return raw.getvalue() + + def parse(self, raw: bytes) -> bytes: + cur = 0 + self.parse_fin_and_rsv(raw[cur]) + cur += 1 + + self.parse_mask_and_payload(raw[cur]) + cur += 1 + + if self.payload_length == 126: + data = raw[cur: cur + 2] + self.payload_length, = struct.unpack('!H', data) + cur += 2 + elif self.payload_length == 127: + data = raw[cur: cur + 8] + self.payload_length, = struct.unpack('!Q', data) + cur += 8 + + if self.masked: + self.mask = raw[cur: cur + 4] + cur += 4 + + assert self.payload_length + self.data = raw[cur: cur + self.payload_length] + cur += self.payload_length + if self.masked: + assert self.mask is not None + self.data = self.apply_mask(self.data, self.mask) + + return raw[cur:] + + @staticmethod + def apply_mask(data: bytes, mask: bytes) -> bytes: + raw = bytearray(data) + for i in range(len(raw)): + raw[i] = raw[i] ^ mask[i % 4] + return bytes(raw) + + @staticmethod + def key_to_accept(key: bytes) -> bytes: + sha1 = hashlib.sha1() + sha1.update(key + WebsocketFrame.GUID) + return base64.b64encode(sha1.digest()) + + +class WebsocketClient(TcpConnection): + + def __init__(self, + hostname: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], + port: int, + path: bytes = b'/', + on_message: Optional[Callable[[WebsocketFrame], None]] = None) -> None: + super().__init__(tcpConnectionTypes.CLIENT) + self.hostname: Union[ipaddress.IPv4Address, + ipaddress.IPv6Address] = hostname + self.port: int = port + self.path: bytes = path + self.sock: socket.socket = new_socket_connection( + (str(self.hostname), self.port)) + self.on_message: Optional[Callable[[ + WebsocketFrame], None]] = on_message + self.upgrade() + self.sock.setblocking(False) + self.selector: selectors.DefaultSelector = selectors.DefaultSelector() + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + return self.sock + + def upgrade(self) -> None: + key = base64.b64encode(secrets.token_bytes(16)) + self.sock.send(build_websocket_handshake_request(key, url=self.path)) + response = HttpParser(httpParserTypes.RESPONSE_PARSER) + response.parse(self.sock.recv(DEFAULT_BUFFER_SIZE)) + accept = response.header(b'Sec-Websocket-Accept') + assert WebsocketFrame.key_to_accept(key) == accept + + def ping(self, data: Optional[bytes] = None) -> None: + pass + + def pong(self, data: Optional[bytes] = None) -> None: + pass + + def shutdown(self, _data: Optional[bytes] = None) -> None: + """Closes connection with the server.""" + super().close() + + def run_once(self) -> bool: + ev = selectors.EVENT_READ + if self.has_buffer(): + ev |= selectors.EVENT_WRITE + self.selector.register(self.sock.fileno(), ev) + events = self.selector.select(timeout=1) + self.selector.unregister(self.sock) + for key, mask in events: + if mask & selectors.EVENT_READ and self.on_message: + raw = self.recv() + if raw is None or raw == b'': + self.closed = True + logger.debug('Websocket connection closed by server') + return True + frame = WebsocketFrame() + frame.parse(raw) + self.on_message(frame) + elif mask & selectors.EVENT_WRITE: + logger.debug(self.buffer) + self.flush() + return False + + def run(self) -> None: + logger.debug('running') + try: + while not self.closed: + teardown = self.run_once() + if teardown: + break + except KeyboardInterrupt: + pass + finally: + try: + self.selector.unregister(self.sock) + self.sock.shutdown(socket.SHUT_WR) + except Exception as e: + logging.exception( + 'Exception while shutdown of websocket client', exc_info=e) + self.sock.close() + logger.info('done') diff --git a/proxy/main.py b/proxy/main.py new file mode 100755 index 0000000000..081460c78b --- /dev/null +++ b/proxy/main.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import base64 +import importlib +import inspect +import ipaddress +import logging +import multiprocessing +import os +import sys +import time +from typing import Dict, List, Optional + +from .common.flags import Flags, init_parser +from .common.utils import text_, bytes_ +from .common.types import DictQueueType +from .common.constants import DOT, COMMA +from .common.constants import DEFAULT_LOG_FORMAT, DEFAULT_LOG_FILE, DEFAULT_LOG_LEVEL +from .common.version import __version__ +from .core.acceptor import AcceptorPool +from .http.handler import HttpProtocolHandler + +if os.name != 'nt': + import resource + +logger = logging.getLogger(__name__) + + +def is_py3() -> bool: + """Exists only to avoid mocking sys.version_info in tests.""" + return sys.version_info[0] == 3 + + +def set_open_file_limit(soft_limit: int) -> None: + """Configure open file description soft limit on supported OS.""" + if os.name != 'nt': # resource module not available on Windows OS + curr_soft_limit, curr_hard_limit = resource.getrlimit( + resource.RLIMIT_NOFILE) + if curr_soft_limit < soft_limit < curr_hard_limit: + resource.setrlimit( + resource.RLIMIT_NOFILE, (soft_limit, curr_hard_limit)) + logger.debug( + 'Open file soft limit set to %d', soft_limit) + + +def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]: + """Accepts a comma separated list of Python modules and returns + a list of respective Python classes.""" + p: Dict[bytes, List[type]] = { + b'HttpProtocolHandlerPlugin': [], + b'HttpProxyBasePlugin': [], + b'HttpWebServerBasePlugin': [], + } + for plugin_ in plugins.split(COMMA): + plugin = text_(plugin_.strip()) + if plugin == '': + continue + module_name, klass_name = plugin.rsplit(text_(DOT), 1) + klass = getattr( + importlib.import_module( + module_name.replace( + os.path.sep, text_(DOT))), + klass_name) + base_klass = inspect.getmro(klass)[1] + p[bytes_(base_klass.__name__)].append(klass) + logger.info( + 'Loaded %s %s.%s', + 'plugin' if klass.__name__ != 'HttpWebServerRouteHandler' else 'route', + module_name, + # HttpWebServerRouteHandler route decorator adds a special + # staticmethod to return decorated function name + klass.__name__ if klass.__name__ != 'HttpWebServerRouteHandler' else klass.name()) + return p + + +def setup_logger( + log_file: Optional[str] = DEFAULT_LOG_FILE, + log_level: str = DEFAULT_LOG_LEVEL, + log_format: str = DEFAULT_LOG_FORMAT) -> None: + ll = getattr( + logging, + {'D': 'DEBUG', + 'I': 'INFO', + 'W': 'WARNING', + 'E': 'ERROR', + 'C': 'CRITICAL'}[log_level.upper()[0]]) + if log_file: + logging.basicConfig( + filename=log_file, + filemode='a', + level=ll, + format=log_format) + else: + logging.basicConfig(level=ll, format=log_format) + + +def main(input_args: List[str]) -> None: + if not is_py3(): + print( + 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' + 'If for some reasons you cannot upgrade, consider using "master" branch or simply ' + '"pip install proxy.py==0.3".' + '\n\n' + 'DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. ' + 'Please upgrade your Python as Python 2.7 won\'t be maintained after that date. ' + 'A future version of pip will drop support for Python 2.7.') + sys.exit(1) + + args = init_parser().parse_args(input_args) + + if args.version: + print(__version__) + sys.exit(0) + + if (args.cert_file and args.key_file) and \ + (args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file): + print('You can either enable end-to-end encryption OR TLS interception,' + 'not both together.') + sys.exit(1) + + try: + setup_logger(args.log_file, args.log_level, args.log_format) + set_open_file_limit(args.open_file_limit) + + auth_code = None + if args.basic_auth: + auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth)) + + default_plugins = '' + devtools_event_queue: Optional[DictQueueType] = None + if args.enable_devtools: + default_plugins += 'proxy.http.devtools.DevtoolsProtocolPlugin,' + default_plugins += 'proxy.http.server.HttpWebServerPlugin,' + if not args.disable_http_proxy: + default_plugins += 'proxy.http.proxy.HttpProxyPlugin,' + if args.enable_web_server or \ + args.pac_file is not None or \ + args.enable_static_server: + if 'proxy.http.server.HttpWebServerPlugin' not in default_plugins: + default_plugins += 'proxy.http.server.HttpWebServerPlugin,' + if args.enable_devtools: + default_plugins += 'proxy.http.devtools.DevtoolsWebsocketPlugin,' + devtools_event_queue = multiprocessing.Manager().Queue() + if args.pac_file is not None: + default_plugins += 'proxy.http.server.HttpWebServerPacFilePlugin,' + + flags = Flags( + auth_code=auth_code, + server_recvbuf_size=args.server_recvbuf_size, + client_recvbuf_size=args.client_recvbuf_size, + pac_file=bytes_(args.pac_file), + pac_file_url_path=bytes_(args.pac_file_url_path), + disable_headers=[ + header.lower() for header in bytes_( + args.disable_headers).split(COMMA) if header.strip() != b''], + certfile=args.cert_file, + keyfile=args.key_file, + ca_cert_dir=args.ca_cert_dir, + ca_key_file=args.ca_key_file, + ca_cert_file=args.ca_cert_file, + ca_signing_key_file=args.ca_signing_key_file, + hostname=ipaddress.ip_address(args.hostname), + port=args.port, + backlog=args.backlog, + num_workers=args.num_workers, + static_server_dir=args.static_server_dir, + enable_static_server=args.enable_static_server, + devtools_event_queue=devtools_event_queue, + devtools_ws_path=args.devtools_ws_path, + timeout=args.timeout, + threadless=args.threadless, + enable_events=args.enable_events) + + flags.plugins = load_plugins( + bytes_( + '%s%s' % + (default_plugins, args.plugins))) + + acceptor_pool = AcceptorPool( + flags=flags, + work_klass=HttpProtocolHandler + ) + + if args.pid_file: + with open(args.pid_file, 'wb') as pid_file: + pid_file.write(bytes_(os.getpid())) + + try: + acceptor_pool.setup() + # TODO: Introduce cron feature instead of mindless sleep + while True: + time.sleep(2**10) + except Exception as e: + logger.exception('exception', exc_info=e) + finally: + acceptor_pool.shutdown() + except KeyboardInterrupt: # pragma: no cover + pass + finally: + if args.pid_file and os.path.exists(args.pid_file): + os.remove(args.pid_file) + + +def entry_point() -> None: + main(sys.argv[1:]) diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/requirements-testing.txt b/requirements-testing.txt index 67eb4da72b..f1f0886d78 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -2,6 +2,8 @@ python-coveralls==2.9.3 coverage==4.5.4 flake8==3.7.9 pytest==5.2.2 +pytest-cov==2.8.1 autopep8==1.4.4 mypy==0.730 py-spy==0.3.0 +codecov==2.0.15 diff --git a/setup.py b/setup.py index 96a9a9451b..af69f96c84 100644 --- a/setup.py +++ b/setup.py @@ -7,75 +7,79 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from setuptools import setup -import proxy +from setuptools import setup, find_packages -classifiers = [ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Environment :: No Input/Output (Daemon)', - 'Environment :: Web Environment', - 'Environment :: MacOS X', - 'Environment :: Plugins', - 'Environment :: Win32 (MS Windows)', - 'Framework :: Robot Framework', - 'Framework :: Robot Framework :: Library', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Operating System :: MacOS', - 'Operating System :: MacOS :: MacOS 9', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Operating System :: Unix', - 'Operating System :: Microsoft', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows 10', - 'Operating System :: Android', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: Implementation', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Internet', - 'Topic :: Internet :: Proxy Servers', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Browsers', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', - 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Topic :: Software Development :: Debuggers', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: System :: Monitoring', - 'Topic :: System :: Networking', - 'Topic :: System :: Networking :: Firewalls', - 'Topic :: System :: Networking :: Monitoring', - 'Topic :: Utilities', - 'Typing :: Typed', -] +from proxy.common.version import __version__ +from proxy.common.constants import __author__, __author_email__, __homepage__, __description__, __download_url__, __license__ setup( name='proxy.py', - version=proxy.__version__, - author=proxy.__author__, - author_email=proxy.__author_email__, - url=proxy.__homepage__, - description=proxy.__description__, + version=__version__, + author=__author__, + author_email=__author_email__, + url=__homepage__, + description=__description__, long_description=open('README.md').read().strip(), long_description_content_type='text/markdown', - download_url=proxy.__download_url__, - classifiers=classifiers, - license=proxy.__license__, - py_modules=['proxy'], - scripts=['proxy.py'], + download_url=__download_url__, + license=__license__, + packages=find_packages(exclude=['benchmark', 'tests', 'plugin_examples']), install_requires=open('requirements.txt', 'r').read().strip().split(), + entry_points={ + 'console_scripts': [ + 'proxy = proxy.main:entry_point' + ] + }, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Environment :: No Input/Output (Daemon)', + 'Environment :: Web Environment', + 'Environment :: MacOS X', + 'Environment :: Plugins', + 'Environment :: Win32 (MS Windows)', + 'Framework :: Robot Framework', + 'Framework :: Robot Framework :: Library', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: MacOS', + 'Operating System :: MacOS :: MacOS 9', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Unix', + 'Operating System :: Microsoft', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft :: Windows :: Windows 10', + 'Operating System :: Android', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: Implementation', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Topic :: Internet', + 'Topic :: Internet :: Proxy Servers', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', + 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'Topic :: Software Development :: Debuggers', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Networking', + 'Topic :: System :: Networking :: Firewalls', + 'Topic :: System :: Networking :: Monitoring', + 'Topic :: Utilities', + 'Typing :: Typed', + ], ) diff --git a/tests.py b/tests.py deleted file mode 100644 index 1445cb904a..0000000000 --- a/tests.py +++ /dev/null @@ -1,2387 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import base64 -import ipaddress -import json -import logging -import multiprocessing -import os -import selectors -import socket -import ssl -import tempfile -import unittest -import uuid -from contextlib import closing -from typing import Dict, Optional, Tuple, Union, Any, cast, Type -from unittest import mock -from urllib import parse as urlparse - -import plugin_examples -import proxy - -if os.name != 'nt': - import resource - -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s') - - -def get_temp_file(name: str) -> str: - return os.path.join(tempfile.gettempdir(), name) - - -def get_available_port() -> int: - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - sock.bind(('', 0)) - _, port = sock.getsockname() - return int(port) - - -def get_plugin_by_test_name(test_name: str) -> Type[proxy.HttpProxyBasePlugin]: - plugin: Type[proxy.HttpProxyBasePlugin] = plugin_examples.ModifyPostDataPlugin - if test_name == 'test_modify_post_data_plugin': - plugin = plugin_examples.ModifyPostDataPlugin - elif test_name == 'test_proposed_rest_api_plugin': - plugin = plugin_examples.ProposedRestApiPlugin - elif test_name == 'test_redirect_to_custom_server_plugin': - plugin = plugin_examples.RedirectToCustomServerPlugin - elif test_name == 'test_filter_by_upstream_host_plugin': - plugin = plugin_examples.FilterByUpstreamHostPlugin - elif test_name == 'test_cache_responses_plugin': - plugin = plugin_examples.CacheResponsesPlugin - elif test_name == 'test_man_in_the_middle_plugin': - plugin = plugin_examples.ManInTheMiddlePlugin - return plugin - - -class TestTextBytes(unittest.TestCase): - - def test_text(self) -> None: - self.assertEqual(proxy.text_(b'hello'), 'hello') - - def test_text_int(self) -> None: - self.assertEqual(proxy.text_(1), '1') - - def test_text_nochange(self) -> None: - self.assertEqual(proxy.text_('hello'), 'hello') - - def test_bytes(self) -> None: - self.assertEqual(proxy.bytes_('hello'), b'hello') - - def test_bytes_int(self) -> None: - self.assertEqual(proxy.bytes_(1), b'1') - - def test_bytes_nochange(self) -> None: - self.assertEqual(proxy.bytes_(b'hello'), b'hello') - - -class TestTcpConnection(unittest.TestCase): - class TcpConnectionToTest(proxy.TcpConnection): - - def __init__(self, conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None, - tag: int = proxy.tcpConnectionTypes.CLIENT) -> None: - super().__init__(tag) - self._conn = conn - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - if self._conn is None: - raise proxy.TcpConnectionUninitializedException() - return self._conn - - def testThrowsKeyErrorIfNoConn(self) -> None: - self.conn = TestTcpConnection.TcpConnectionToTest() - with self.assertRaises(proxy.TcpConnectionUninitializedException): - self.conn.send(b'dummy') - with self.assertRaises(proxy.TcpConnectionUninitializedException): - self.conn.recv() - with self.assertRaises(proxy.TcpConnectionUninitializedException): - self.conn.close() - - def testClosesIfNotClosed(self) -> None: - _conn = mock.MagicMock() - self.conn = TestTcpConnection.TcpConnectionToTest(_conn) - self.conn.close() - _conn.close.assert_called() - self.assertTrue(self.conn.closed) - - def testNoOpIfAlreadyClosed(self) -> None: - _conn = mock.MagicMock() - self.conn = TestTcpConnection.TcpConnectionToTest(_conn) - self.conn.closed = True - self.conn.close() - _conn.close.assert_not_called() - self.assertTrue(self.conn.closed) - - def testFlushReturnsIfNoBuffer(self) -> None: - _conn = mock.MagicMock() - self.conn = TestTcpConnection.TcpConnectionToTest(_conn) - self.conn.flush() - self.assertTrue(not _conn.send.called) - - @mock.patch('socket.socket') - def testTcpServerEstablishesIPv6Connection( - self, mock_socket: mock.Mock) -> None: - conn = proxy.TcpServerConnection( - str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT) - conn.connect() - mock_socket.assert_called() - mock_socket.return_value.connect.assert_called_with( - (str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT, 0, 0)) - - @mock.patch('proxy.new_socket_connection') - def testTcpServerIgnoresDoubleConnectSilently( - self, - mock_new_socket_connection: mock.Mock) -> None: - conn = proxy.TcpServerConnection( - str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT) - conn.connect() - conn.connect() - mock_new_socket_connection.assert_called_once() - - @mock.patch('socket.socket') - def testTcpServerEstablishesIPv4Connection( - self, mock_socket: mock.Mock) -> None: - conn = proxy.TcpServerConnection( - str(proxy.DEFAULT_IPV4_HOSTNAME), proxy.DEFAULT_PORT) - conn.connect() - mock_socket.assert_called() - mock_socket.return_value.connect.assert_called_with( - (str(proxy.DEFAULT_IPV4_HOSTNAME), proxy.DEFAULT_PORT)) - - @mock.patch('proxy.new_socket_connection') - def testTcpServerConnectionProperty( - self, - mock_new_socket_connection: mock.Mock) -> None: - conn = proxy.TcpServerConnection( - str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT) - conn.connect() - self.assertEqual(conn.connection, mock_new_socket_connection.return_value) - - def testTcpServerRaisesTcpConnectionUninitializedException(self) -> None: - conn = proxy.TcpServerConnection( - str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT) - with self.assertRaises(proxy.TcpConnectionUninitializedException): - _ = conn.connection - - def testTcpClientRaisesTcpConnectionUninitializedException(self) -> None: - _conn = mock.MagicMock() - _addr = mock.MagicMock() - conn = proxy.TcpClientConnection(_conn, _addr) - conn._conn = None - with self.assertRaises(proxy.TcpConnectionUninitializedException): - _ = conn.connection - - -class TestSocketConnectionUtils(unittest.TestCase): - - def setUp(self) -> None: - self.addr_ipv4 = (str(proxy.DEFAULT_IPV4_HOSTNAME), proxy.DEFAULT_PORT) - self.addr_ipv6 = (str(proxy.DEFAULT_IPV6_HOSTNAME), proxy.DEFAULT_PORT) - self.addr_dual = ('httpbin.org', 80) - - @mock.patch('socket.socket') - def test_new_socket_connection_ipv4(self, mock_socket: mock.Mock) -> None: - conn = proxy.new_socket_connection(self.addr_ipv4) - mock_socket.assert_called_with(socket.AF_INET, socket.SOCK_STREAM, 0) - self.assertEqual(conn, mock_socket.return_value) - mock_socket.return_value.connect.assert_called_with(self.addr_ipv4) - - @mock.patch('socket.socket') - def test_new_socket_connection_ipv6(self, mock_socket: mock.Mock) -> None: - conn = proxy.new_socket_connection(self.addr_ipv6) - mock_socket.assert_called_with(socket.AF_INET6, socket.SOCK_STREAM, 0) - self.assertEqual(conn, mock_socket.return_value) - mock_socket.return_value.connect.assert_called_with( - (self.addr_ipv6[0], self.addr_ipv6[1], 0, 0)) - - @mock.patch('socket.create_connection') - def test_new_socket_connection_dual(self, mock_socket: mock.Mock) -> None: - conn = proxy.new_socket_connection(self.addr_dual) - mock_socket.assert_called_with(self.addr_dual) - self.assertEqual(conn, mock_socket.return_value) - - @mock.patch('proxy.new_socket_connection') - def test_decorator(self, mock_new_socket_connection: mock.Mock) -> None: - @proxy.socket_connection(self.addr_ipv4) - def dummy(conn: socket.socket) -> None: - self.assertEqual(conn, mock_new_socket_connection.return_value) - dummy() # type: ignore - - @mock.patch('proxy.new_socket_connection') - def test_context_manager(self, mock_new_socket_connection: mock.Mock) -> None: - with proxy.socket_connection(self.addr_ipv4) as conn: - self.assertEqual(conn, mock_new_socket_connection.return_value) - - -class TestAcceptorPool(unittest.TestCase): - - @mock.patch('proxy.send_handle') - @mock.patch('multiprocessing.Pipe') - @mock.patch('socket.socket') - @mock.patch('proxy.Acceptor') - def test_setup_and_shutdown( - self, - mock_worker: mock.Mock, - mock_socket: mock.Mock, - mock_pipe: mock.Mock, - _mock_send_handle: mock.Mock) -> None: - mock_worker1 = mock.MagicMock() - mock_worker2 = mock.MagicMock() - mock_worker.side_effect = [mock_worker1, mock_worker2] - - num_workers = 2 - sock = mock_socket.return_value - work_klass = mock.MagicMock() - kwargs = {'config': proxy.ProtocolConfig()} - acceptor = proxy.AcceptorPool( - ipaddress.ip_address(proxy.DEFAULT_IPV6_HOSTNAME), - proxy.DEFAULT_PORT, - proxy.DEFAULT_BACKLOG, - num_workers, - threadless=proxy.DEFAULT_THREADLESS, - work_klass=work_klass, - **kwargs - ) - - acceptor.setup() - - mock_socket.assert_called_with( - socket.AF_INET6 if acceptor.hostname.version == 6 else socket.AF_INET, - socket.SOCK_STREAM - ) - sock.setsockopt.assert_called_with(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind.assert_called_with((str(acceptor.hostname), acceptor.port)) - sock.listen.assert_called_with(acceptor.backlog) - sock.setblocking.assert_called_with(False) - - self.assertTrue(mock_pipe.call_count, num_workers) - self.assertTrue(mock_worker.call_count, num_workers) - mock_worker1.start.assert_called() - mock_worker1.join.assert_not_called() - mock_worker2.start.assert_called() - mock_worker2.join.assert_not_called() - - sock.close.assert_called() - - acceptor.shutdown() - mock_worker1.join.assert_called() - mock_worker2.join.assert_called() - - -class TestWorker(unittest.TestCase): - - @mock.patch('proxy.ProtocolHandler') - def setUp( - self, - mock_protocol_handler: mock.Mock) -> None: - self.mock_protocol_handler = mock_protocol_handler - self.pipe = multiprocessing.Pipe() - self.protocol_config = proxy.ProtocolConfig() - self.worker = proxy.Acceptor( - socket.AF_INET6, - proxy.DEFAULT_THREADLESS, - self.pipe[1], - mock_protocol_handler, - config=self.protocol_config) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - @mock.patch('proxy.recv_handle') - def test_continues_when_no_events( - self, - mock_recv_handle: mock.Mock, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - fileno = 10 - conn = mock.MagicMock() - addr = mock.MagicMock() - sock = mock_fromfd.return_value - mock_fromfd.return_value.accept.return_value = (conn, addr) - mock_recv_handle.return_value = fileno - - selector = mock_selector.return_value - selector.select.side_effect = [[], KeyboardInterrupt()] - - self.worker.run() - - sock.accept.assert_not_called() - self.mock_protocol_handler.assert_not_called() - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - @mock.patch('proxy.recv_handle') - def test_worker_doesnt_teardown_on_blocking_io_error( - self, - mock_recv_handle: mock.Mock, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - fileno = 10 - conn = mock.MagicMock() - addr = mock.MagicMock() - sock = mock_fromfd.return_value - mock_fromfd.return_value.accept.return_value = (conn, addr) - mock_recv_handle.return_value = fileno - - selector = mock_selector.return_value - selector.select.side_effect = [(None, None), KeyboardInterrupt()] - sock.accept.side_effect = BlockingIOError() - - self.worker.run() - - self.mock_protocol_handler.assert_not_called() - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - @mock.patch('proxy.recv_handle') - def test_accepts_client_from_server_socket( - self, - mock_recv_handle: mock.Mock, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - fileno = 10 - conn = mock.MagicMock() - addr = mock.MagicMock() - sock = mock_fromfd.return_value - mock_fromfd.return_value.accept.return_value = (conn, addr) - mock_recv_handle.return_value = fileno - - self.mock_protocol_handler.return_value.start.side_effect = KeyboardInterrupt() - - selector = mock_selector.return_value - selector.select.return_value = [(None, None)] - - self.worker.run() - - selector.register.assert_called_with(sock, selectors.EVENT_READ) - selector.unregister.assert_called_with(sock) - mock_recv_handle.assert_called_with(self.pipe[1]) - mock_fromfd.assert_called_with( - fileno, - family=socket.AF_INET6, - type=socket.SOCK_STREAM - ) - self.mock_protocol_handler.assert_called_with( - fileno=conn.fileno(), - addr=addr, - **{'config': self.protocol_config} - ) - # self.mock_protocol_handler.return_value.setDaemon.assert_called() - self.mock_protocol_handler.return_value.start.assert_called() - sock.close.assert_called() - - -class TestChunkParser(unittest.TestCase): - - def setUp(self) -> None: - self.parser = proxy.ChunkParser() - - def test_chunk_parse_basic(self) -> None: - self.parser.parse(b''.join([ - b'4\r\n', - b'Wiki\r\n', - b'5\r\n', - b'pedia\r\n', - b'E\r\n', - b' in\r\n\r\nchunks.\r\n', - b'0\r\n', - b'\r\n' - ])) - self.assertEqual(self.parser.chunk, b'') - self.assertEqual(self.parser.size, None) - self.assertEqual(self.parser.body, b'Wikipedia in\r\n\r\nchunks.') - self.assertEqual(self.parser.state, proxy.chunkParserStates.COMPLETE) - - def test_chunk_parse_issue_27(self) -> None: - """Case when data ends with the chunk size but without ending CRLF.""" - self.parser.parse(b'3') - self.assertEqual(self.parser.chunk, b'3') - self.assertEqual(self.parser.size, None) - self.assertEqual(self.parser.body, b'') - self.assertEqual( - self.parser.state, - proxy.chunkParserStates.WAITING_FOR_SIZE) - self.parser.parse(b'\r\n') - self.assertEqual(self.parser.chunk, b'') - self.assertEqual(self.parser.size, 3) - self.assertEqual(self.parser.body, b'') - self.assertEqual( - self.parser.state, - proxy.chunkParserStates.WAITING_FOR_DATA) - self.parser.parse(b'abc') - self.assertEqual(self.parser.chunk, b'') - self.assertEqual(self.parser.size, None) - self.assertEqual(self.parser.body, b'abc') - self.assertEqual( - self.parser.state, - proxy.chunkParserStates.WAITING_FOR_SIZE) - self.parser.parse(b'\r\n') - self.assertEqual(self.parser.chunk, b'') - self.assertEqual(self.parser.size, None) - self.assertEqual(self.parser.body, b'abc') - self.assertEqual( - self.parser.state, - proxy.chunkParserStates.WAITING_FOR_SIZE) - self.parser.parse(b'4\r\n') - self.assertEqual(self.parser.chunk, b'') - self.assertEqual(self.parser.size, 4) - self.assertEqual(self.parser.body, b'abc') - self.assertEqual( - self.parser.state, - proxy.chunkParserStates.WAITING_FOR_DATA) - self.parser.parse(b'defg\r\n0') - self.assertEqual(self.parser.chunk, b'0') - self.assertEqual(self.parser.size, None) - self.assertEqual(self.parser.body, b'abcdefg') - self.assertEqual( - self.parser.state, - proxy.chunkParserStates.WAITING_FOR_SIZE) - self.parser.parse(b'\r\n\r\n') - self.assertEqual(self.parser.chunk, b'') - self.assertEqual(self.parser.size, None) - self.assertEqual(self.parser.body, b'abcdefg') - self.assertEqual(self.parser.state, proxy.chunkParserStates.COMPLETE) - - def test_to_chunks(self) -> None: - self.assertEqual(b'f\r\n{"key":"value"}\r\n0\r\n\r\n', proxy.ChunkParser.to_chunks(b'{"key":"value"}')) - - -class TestHttpParser(unittest.TestCase): - - def setUp(self) -> None: - self.parser = proxy.HttpParser(proxy.httpParserTypes.REQUEST_PARSER) - - def test_build_request(self) -> None: - self.assertEqual( - proxy.build_http_request( - b'GET', b'http://localhost:12345', b'HTTP/1.1'), - proxy.CRLF.join([ - b'GET http://localhost:12345 HTTP/1.1', - proxy.CRLF - ])) - self.assertEqual( - proxy.build_http_request(b'GET', b'http://localhost:12345', b'HTTP/1.1', - headers={b'key': b'value'}), - proxy.CRLF.join([ - b'GET http://localhost:12345 HTTP/1.1', - b'key: value', - proxy.CRLF - ])) - self.assertEqual( - proxy.build_http_request(b'GET', b'http://localhost:12345', b'HTTP/1.1', - headers={b'key': b'value'}, - body=b'Hello from proxy.py'), - proxy.CRLF.join([ - b'GET http://localhost:12345 HTTP/1.1', - b'key: value', - proxy.CRLF - ]) + b'Hello from proxy.py') - - def test_build_response(self) -> None: - self.assertEqual( - proxy.build_http_response( - 200, reason=b'OK', protocol_version=b'HTTP/1.1'), - proxy.CRLF.join([ - b'HTTP/1.1 200 OK', - proxy.CRLF - ])) - self.assertEqual( - proxy.build_http_response(200, reason=b'OK', protocol_version=b'HTTP/1.1', - headers={b'key': b'value'}), - proxy.CRLF.join([ - b'HTTP/1.1 200 OK', - b'key: value', - proxy.CRLF - ])) - - def test_build_response_adds_content_length_header(self) -> None: - body = b'Hello world!!!' - self.assertEqual( - proxy.build_http_response(200, reason=b'OK', protocol_version=b'HTTP/1.1', - headers={b'key': b'value'}, - body=body), - proxy.CRLF.join([ - b'HTTP/1.1 200 OK', - b'key: value', - b'Content-Length: ' + proxy.bytes_(len(body)), - proxy.CRLF - ]) + body) - - def test_build_header(self) -> None: - self.assertEqual( - proxy.build_http_header( - b'key', b'value'), b'key: value') - - def test_header_raises(self) -> None: - with self.assertRaises(KeyError): - self.parser.header(b'not-found') - - def test_has_header(self) -> None: - self.parser.add_header(b'key', b'value') - self.assertFalse(self.parser.has_header(b'not-found')) - self.assertTrue(self.parser.has_header(b'key')) - - def test_set_host_port_raises(self) -> None: - with self.assertRaises(KeyError): - self.parser.set_line_attributes() - - def test_find_line(self) -> None: - self.assertEqual( - proxy.find_http_line( - b'CONNECT python.org:443 HTTP/1.0\r\n\r\n'), - (b'CONNECT python.org:443 HTTP/1.0', - proxy.CRLF)) - - def test_find_line_returns_None(self) -> None: - self.assertEqual( - proxy.find_http_line(b'CONNECT python.org:443 HTTP/1.0'), - (None, - b'CONNECT python.org:443 HTTP/1.0')) - - def test_connect_request_with_crlf_as_separate_chunk(self) -> None: - """See https://github.com/abhinavsingh/proxy.py/issues/70 for background.""" - raw = b'CONNECT pypi.org:443 HTTP/1.0\r\n' - self.parser.parse(raw) - self.assertEqual(self.parser.state, proxy.httpParserStates.LINE_RCVD) - self.parser.parse(proxy.CRLF) - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_get_full_parse(self) -> None: - raw = proxy.CRLF.join([ - b'GET %s HTTP/1.1', - b'Host: %s', - proxy.CRLF - ]) - pkt = raw % (b'https://example.com/path/dir/?a=b&c=d#p=q', - b'example.com') - self.parser.parse(pkt) - self.assertEqual(self.parser.total_size, len(pkt)) - self.assertEqual(self.parser.build_url(), b'/path/dir/?a=b&c=d#p=q') - self.assertEqual(self.parser.method, b'GET') - assert self.parser.url - self.assertEqual(self.parser.url.hostname, b'example.com') - self.assertEqual(self.parser.url.port, None) - self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - self.assertDictContainsSubset( - {b'host': (b'Host', b'example.com')}, self.parser.headers) - self.parser.del_headers([b'host']) - self.parser.add_headers([(b'Host', b'example.com')]) - self.assertEqual( - raw % - (b'/path/dir/?a=b&c=d#p=q', - b'example.com'), - self.parser.build()) - - def test_build_url_none(self) -> None: - self.assertEqual(self.parser.build_url(), b'/None') - - def test_line_rcvd_to_rcving_headers_state_change(self) -> None: - pkt = b'GET http://localhost HTTP/1.1' - self.parser.parse(pkt) - self.assertEqual(self.parser.total_size, len(pkt)) - self.assert_state_change_with_crlf( - proxy.httpParserStates.INITIALIZED, - proxy.httpParserStates.LINE_RCVD, - proxy.httpParserStates.COMPLETE) - - def test_get_partial_parse1(self) -> None: - pkt = proxy.CRLF.join([ - b'GET http://localhost:8080 HTTP/1.1' - ]) - self.parser.parse(pkt) - self.assertEqual(self.parser.total_size, len(pkt)) - self.assertEqual(self.parser.method, None) - self.assertEqual(self.parser.url, None) - self.assertEqual(self.parser.version, None) - self.assertEqual( - self.parser.state, - proxy.httpParserStates.INITIALIZED) - - self.parser.parse(proxy.CRLF) - self.assertEqual(self.parser.total_size, len(pkt) + len(proxy.CRLF)) - self.assertEqual(self.parser.method, b'GET') - assert self.parser.url - self.assertEqual(self.parser.url.hostname, b'localhost') - self.assertEqual(self.parser.url.port, 8080) - self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assertEqual(self.parser.state, proxy.httpParserStates.LINE_RCVD) - - host_hdr = b'Host: localhost:8080' - self.parser.parse(host_hdr) - self.assertEqual(self.parser.total_size, - len(pkt) + len(proxy.CRLF) + len(host_hdr)) - self.assertDictEqual(self.parser.headers, dict()) - self.assertEqual(self.parser.buffer, b'Host: localhost:8080') - self.assertEqual(self.parser.state, proxy.httpParserStates.LINE_RCVD) - - self.parser.parse(proxy.CRLF * 2) - self.assertEqual(self.parser.total_size, len(pkt) + - (3 * len(proxy.CRLF)) + len(host_hdr)) - self.assertDictContainsSubset( - {b'host': (b'Host', b'localhost:8080')}, self.parser.headers) - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_get_partial_parse2(self) -> None: - self.parser.parse(proxy.CRLF.join([ - b'GET http://localhost:8080 HTTP/1.1', - b'Host: ' - ])) - self.assertEqual(self.parser.method, b'GET') - assert self.parser.url - self.assertEqual(self.parser.url.hostname, b'localhost') - self.assertEqual(self.parser.url.port, 8080) - self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assertEqual(self.parser.buffer, b'Host: ') - self.assertEqual(self.parser.state, proxy.httpParserStates.LINE_RCVD) - - self.parser.parse(b'localhost:8080' + proxy.CRLF) - self.assertDictContainsSubset( - {b'host': (b'Host', b'localhost:8080')}, self.parser.headers) - self.assertEqual(self.parser.buffer, b'') - self.assertEqual( - self.parser.state, - proxy.httpParserStates.RCVING_HEADERS) - - self.parser.parse(b'Content-Type: text/plain' + proxy.CRLF) - self.assertEqual(self.parser.buffer, b'') - self.assertDictContainsSubset( - {b'content-type': (b'Content-Type', b'text/plain')}, self.parser.headers) - self.assertEqual( - self.parser.state, - proxy.httpParserStates.RCVING_HEADERS) - - self.parser.parse(proxy.CRLF) - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_post_full_parse(self) -> None: - raw = proxy.CRLF.join([ - b'POST %s HTTP/1.1', - b'Host: localhost', - b'Content-Length: 7', - b'Content-Type: application/x-www-form-urlencoded' + proxy.CRLF, - b'a=b&c=d' - ]) - self.parser.parse(raw % b'http://localhost') - self.assertEqual(self.parser.method, b'POST') - assert self.parser.url - self.assertEqual(self.parser.url.hostname, b'localhost') - self.assertEqual(self.parser.url.port, None) - self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assertDictContainsSubset( - {b'content-type': (b'Content-Type', b'application/x-www-form-urlencoded')}, self.parser.headers) - self.assertDictContainsSubset( - {b'content-length': (b'Content-Length', b'7')}, self.parser.headers) - self.assertEqual(self.parser.body, b'a=b&c=d') - self.assertEqual(self.parser.buffer, b'') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - self.assertEqual(len(self.parser.build()), len(raw % b'/')) - - def assert_state_change_with_crlf(self, - initial_state: int, - next_state: int, - final_state: int) -> None: - self.assertEqual(self.parser.state, initial_state) - self.parser.parse(proxy.CRLF) - self.assertEqual(self.parser.state, next_state) - self.parser.parse(proxy.CRLF) - self.assertEqual(self.parser.state, final_state) - - def test_post_partial_parse(self) -> None: - self.parser.parse(proxy.CRLF.join([ - b'POST http://localhost HTTP/1.1', - b'Host: localhost', - b'Content-Length: 7', - b'Content-Type: application/x-www-form-urlencoded' - ])) - self.assertEqual(self.parser.method, b'POST') - assert self.parser.url - self.assertEqual(self.parser.url.hostname, b'localhost') - self.assertEqual(self.parser.url.port, None) - self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assert_state_change_with_crlf( - proxy.httpParserStates.RCVING_HEADERS, - proxy.httpParserStates.RCVING_HEADERS, - proxy.httpParserStates.HEADERS_COMPLETE) - - self.parser.parse(b'a=b') - self.assertEqual( - self.parser.state, - proxy.httpParserStates.RCVING_BODY) - self.assertEqual(self.parser.body, b'a=b') - self.assertEqual(self.parser.buffer, b'') - - self.parser.parse(b'&c=d') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - self.assertEqual(self.parser.body, b'a=b&c=d') - self.assertEqual(self.parser.buffer, b'') - - def test_connect_request_without_host_header_request_parse(self) -> None: - """Case where clients can send CONNECT request without a Host header field. - - Example: - 1. pip3 --proxy http://localhost:8899 install - Uses HTTP/1.0, Host header missing with CONNECT requests - 2. Android Emulator - Uses HTTP/1.1, Host header missing with CONNECT requests - - See https://github.com/abhinavsingh/proxy.py/issues/5 for details. - """ - self.parser.parse(b'CONNECT pypi.org:443 HTTP/1.0\r\n\r\n') - self.assertEqual(self.parser.method, b'CONNECT') - self.assertEqual(self.parser.version, b'HTTP/1.0') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_request_parse_without_content_length(self) -> None: - """Case when incoming request doesn't contain a content-length header. - - From http://w3-org.9356.n7.nabble.com/POST-with-empty-body-td103965.html - 'A POST with no content-length and no body is equivalent to a POST with Content-Length: 0 - and nothing following, as could perfectly happen when you upload an empty file for instance.' - - See https://github.com/abhinavsingh/proxy.py/issues/20 for details. - """ - self.parser.parse(proxy.CRLF.join([ - b'POST http://localhost HTTP/1.1', - b'Host: localhost', - b'Content-Type: application/x-www-form-urlencoded', - proxy.CRLF - ])) - self.assertEqual(self.parser.method, b'POST') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_response_parse_without_content_length(self) -> None: - """Case when server response doesn't contain a content-length header for non-chunk response types. - - HttpParser by itself has no way to know if more data should be expected. - In example below, parser reaches state httpParserStates.HEADERS_COMPLETE - and it is responsibility of callee to change state to httpParserStates.COMPLETE - when server stream closes. - - See https://github.com/abhinavsingh/proxy.py/issues/20 for details. - """ - self.parser.type = proxy.httpParserTypes.RESPONSE_PARSER - self.parser.parse(b'HTTP/1.0 200 OK' + proxy.CRLF) - self.assertEqual(self.parser.code, b'200') - self.assertEqual(self.parser.version, b'HTTP/1.0') - self.assertEqual(self.parser.state, proxy.httpParserStates.LINE_RCVD) - self.parser.parse(proxy.CRLF.join([ - b'Server: BaseHTTP/0.3 Python/2.7.10', - b'Date: Thu, 13 Dec 2018 16:24:09 GMT', - proxy.CRLF - ])) - self.assertEqual( - self.parser.state, - proxy.httpParserStates.HEADERS_COMPLETE) - - def test_response_parse(self) -> None: - self.parser.type = proxy.httpParserTypes.RESPONSE_PARSER - self.parser.parse(b''.join([ - b'HTTP/1.1 301 Moved Permanently\r\n', - b'Location: http://www.google.com/\r\n', - b'Content-Type: text/html; charset=UTF-8\r\n', - b'Date: Wed, 22 May 2013 14:07:29 GMT\r\n', - b'Expires: Fri, 21 Jun 2013 14:07:29 GMT\r\n', - b'Cache-Control: public, max-age=2592000\r\n', - b'Server: gws\r\n', - b'Content-Length: 219\r\n', - b'X-XSS-Protection: 1; mode=block\r\n', - b'X-Frame-Options: SAMEORIGIN\r\n\r\n', - b'\n' + - b'301 Moved', - b'\n

301 Moved

\nThe document has moved\n' + - b'here.\r\n\r\n' - ])) - self.assertEqual(self.parser.code, b'301') - self.assertEqual(self.parser.reason, b'Moved Permanently') - self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assertEqual( - self.parser.body, - b'\n' + - b'301 Moved\n

301 Moved

\nThe document has moved\n' + - b'here.\r\n\r\n') - self.assertDictContainsSubset( - {b'content-length': (b'Content-Length', b'219')}, self.parser.headers) - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_response_partial_parse(self) -> None: - self.parser.type = proxy.httpParserTypes.RESPONSE_PARSER - self.parser.parse(b''.join([ - b'HTTP/1.1 301 Moved Permanently\r\n', - b'Location: http://www.google.com/\r\n', - b'Content-Type: text/html; charset=UTF-8\r\n', - b'Date: Wed, 22 May 2013 14:07:29 GMT\r\n', - b'Expires: Fri, 21 Jun 2013 14:07:29 GMT\r\n', - b'Cache-Control: public, max-age=2592000\r\n', - b'Server: gws\r\n', - b'Content-Length: 219\r\n', - b'X-XSS-Protection: 1; mode=block\r\n', - b'X-Frame-Options: SAMEORIGIN\r\n' - ])) - self.assertDictContainsSubset( - {b'x-frame-options': (b'X-Frame-Options', b'SAMEORIGIN')}, self.parser.headers) - self.assertEqual( - self.parser.state, - proxy.httpParserStates.RCVING_HEADERS) - self.parser.parse(b'\r\n') - self.assertEqual( - self.parser.state, - proxy.httpParserStates.HEADERS_COMPLETE) - self.parser.parse( - b'\n' + - b'301 Moved') - self.assertEqual( - self.parser.state, - proxy.httpParserStates.RCVING_BODY) - self.parser.parse( - b'\n

301 Moved

\nThe document has moved\n' + - b'here.\r\n\r\n') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_chunked_response_parse(self) -> None: - self.parser.type = proxy.httpParserTypes.RESPONSE_PARSER - self.parser.parse(b''.join([ - b'HTTP/1.1 200 OK\r\n', - b'Content-Type: application/json\r\n', - b'Date: Wed, 22 May 2013 15:08:15 GMT\r\n', - b'Server: gunicorn/0.16.1\r\n', - b'transfer-encoding: chunked\r\n', - b'Connection: keep-alive\r\n\r\n', - b'4\r\n', - b'Wiki\r\n', - b'5\r\n', - b'pedia\r\n', - b'E\r\n', - b' in\r\n\r\nchunks.\r\n', - b'0\r\n', - b'\r\n' - ])) - self.assertEqual(self.parser.body, b'Wikipedia in\r\n\r\nchunks.') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - - def test_pipelined_response_parse(self) -> None: - response = proxy.build_http_response( - proxy.httpStatusCodes.OK, reason=b'OK', - headers={ - b'Content-Length': b'15' - }, - body=b'{"key":"value"}', - ) - self.assert_pipeline_response(response) - - def test_pipelined_chunked_response_parse(self) -> None: - response = proxy.build_http_response( - proxy.httpStatusCodes.OK, reason=b'OK', - headers={ - b'Transfer-Encoding': b'chunked', - b'Content-Type': b'application/json', - }, - body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n' - ) - self.assert_pipeline_response(response) - - def assert_pipeline_response(self, response: bytes) -> None: - self.parser = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) - self.parser.parse(response + response) - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - self.assertEqual(self.parser.body, b'{"key":"value"}') - self.assertEqual(self.parser.buffer, response) - - # parse buffer - parser = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) - parser.parse(self.parser.buffer) - self.assertEqual(parser.state, proxy.httpParserStates.COMPLETE) - self.assertEqual(parser.body, b'{"key":"value"}') - self.assertEqual(parser.buffer, b'') - - def test_chunked_request_parse(self) -> None: - self.parser.parse(proxy.build_http_request( - proxy.httpMethods.POST, b'http://example.org/', - headers={ - b'Transfer-Encoding': b'chunked', - b'Content-Type': b'application/json', - }, - body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n')) - self.assertEqual(self.parser.body, b'{"key":"value"}') - self.assertEqual(self.parser.state, proxy.httpParserStates.COMPLETE) - self.assertEqual(self.parser.build(), proxy.build_http_request( - proxy.httpMethods.POST, b'/', - headers={ - b'Transfer-Encoding': b'chunked', - b'Content-Type': b'application/json', - }, - body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n')) - - def test_is_http_1_1_keep_alive(self) -> None: - self.parser.parse(proxy.build_http_request( - proxy.httpMethods.GET, b'/' - )) - self.assertTrue(self.parser.is_http_1_1_keep_alive()) - - def test_is_http_1_1_keep_alive_with_non_close_connection_header(self) -> None: - self.parser.parse(proxy.build_http_request( - proxy.httpMethods.GET, b'/', - headers={ - b'Connection': b'keep-alive', - } - )) - self.assertTrue(self.parser.is_http_1_1_keep_alive()) - - def test_is_not_http_1_1_keep_alive_with_close_header(self) -> None: - self.parser.parse(proxy.build_http_request( - proxy.httpMethods.GET, b'/', - headers={ - b'Connection': b'close', - } - )) - self.assertFalse(self.parser.is_http_1_1_keep_alive()) - - def test_is_not_http_1_1_keep_alive_for_http_1_0(self) -> None: - self.parser.parse(proxy.build_http_request( - proxy.httpMethods.GET, b'/', protocol_version=b'HTTP/1.0', - )) - self.assertFalse(self.parser.is_http_1_1_keep_alive()) - - def assertDictContainsSubset(self, subset: Dict[bytes, Tuple[bytes, bytes]], - dictionary: Dict[bytes, Tuple[bytes, bytes]]) -> None: - for k in subset.keys(): - self.assertTrue(k in dictionary) - - -class TestWebsocketFrame(unittest.TestCase): - - def test_build_with_mask(self) -> None: - raw = b'\x81\x85\xc6\ti\x8d\xael\x05\xe1\xa9' - frame = proxy.WebsocketFrame() - frame.fin = True - frame.opcode = proxy.websocketOpcodes.TEXT_FRAME - frame.masked = True - frame.mask = b'\xc6\ti\x8d' - frame.data = b'hello' - self.assertEqual(frame.build(), raw) - - def test_parse_with_mask(self) -> None: - raw = b'\x81\x85\xc6\ti\x8d\xael\x05\xe1\xa9' - frame = proxy.WebsocketFrame() - frame.parse(raw) - self.assertEqual(frame.fin, True) - self.assertEqual(frame.rsv1, False) - self.assertEqual(frame.rsv2, False) - self.assertEqual(frame.rsv3, False) - self.assertEqual(frame.opcode, 0x1) - self.assertEqual(frame.masked, True) - assert frame.mask is not None - self.assertEqual(frame.mask, b'\xc6\ti\x8d') - self.assertEqual(frame.payload_length, 5) - self.assertEqual(frame.data, b'hello') - - -class TestWebsocketClient(unittest.TestCase): - - @mock.patch('base64.b64encode') - @mock.patch('proxy.new_socket_connection') - def test_handshake(self, mock_connect: mock.Mock, mock_b64encode: mock.Mock) -> None: - key = b'MySecretKey' - mock_b64encode.return_value = key - mock_connect.return_value.recv.return_value = \ - proxy.build_websocket_handshake_response(proxy.WebsocketFrame.key_to_accept(key)) - _ = proxy.WebsocketClient(proxy.DEFAULT_IPV4_HOSTNAME, 8899) - mock_connect.return_value.send.assert_called_with( - proxy.build_websocket_handshake_request(key) - ) - - -class TestHttpProtocolHandler(unittest.TestCase): - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def setUp(self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self._conn = mock_fromfd.return_value - - self.http_server_port = 65535 - self.config = proxy.ProtocolConfig() - self.config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - - self.mock_selector = mock_selector - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=self.config) - self.proxy.initialize() - - @mock.patch('proxy.TcpServerConnection') - def test_http_get(self, mock_server_connection: mock.Mock) -> None: - server = mock_server_connection.return_value - server.connect.return_value = True - server.buffer_size.return_value = 0 - self.mock_selector_for_client_read_read_server_write(self.mock_selector, server) - - # Send request line - assert self.http_server_port is not None - self._conn.recv.return_value = (b'GET http://localhost:%d HTTP/1.1' % - self.http_server_port) + proxy.CRLF - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.LINE_RCVD) - self.assertNotEqual( - self.proxy.request.state, - proxy.httpParserStates.COMPLETE) - - # Send headers and blank line, thus completing HTTP request - assert self.http_server_port is not None - self._conn.recv.return_value = proxy.CRLF.join([ - b'User-Agent: proxy.py/%s' % proxy.version, - b'Host: localhost:%d' % self.http_server_port, - b'Accept: */*', - b'Proxy-Connection: Keep-Alive', - proxy.CRLF - ]) - self.assert_data_queued(mock_server_connection, server) - self.proxy.run_once() - server.flush.assert_called_once() - - def assert_tunnel_response( - self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: - self.proxy.run_once() - self.assertTrue( - cast(proxy.HttpProxyPlugin, self.proxy.plugins['HttpProxyPlugin']).server is not None) - self.assertEqual( - self.proxy.client.buffer, - proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) - mock_server_connection.assert_called_once() - server.connect.assert_called_once() - server.queue.assert_not_called() - server.closed = False - - parser = proxy.HttpParser(proxy.httpParserTypes.RESPONSE_PARSER) - parser.parse(self.proxy.client.buffer) - self.assertEqual(parser.state, proxy.httpParserStates.COMPLETE) - assert parser.code is not None - self.assertEqual(int(parser.code), 200) - - @mock.patch('proxy.TcpServerConnection') - def test_http_tunnel(self, mock_server_connection: mock.Mock) -> None: - server = mock_server_connection.return_value - server.connect.return_value = True - - def has_buffer() -> bool: - return cast(bool, server.queue.called) - - server.has_buffer.side_effect = has_buffer - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ], - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=0, - data=None), selectors.EVENT_WRITE), ], - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ], - [(selectors.SelectorKey( - fileobj=server.connection, - fd=server.connection.fileno, - events=0, - data=None), selectors.EVENT_WRITE), ], - ] - - assert self.http_server_port is not None - self._conn.recv.return_value = proxy.CRLF.join([ - b'CONNECT localhost:%d HTTP/1.1' % self.http_server_port, - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % proxy.version, - b'Proxy-Connection: Keep-Alive', - proxy.CRLF - ]) - self.assert_tunnel_response(mock_server_connection, server) - - # Dispatch tunnel established response to client - self.proxy.run_once() - self.assert_data_queued_to_server(server) - - self.proxy.run_once() - self.assertEqual(server.queue.call_count, 1) - server.flush.assert_called_once() - - def test_proxy_connection_failed(self) -> None: - self.mock_selector_for_client_read(self.mock_selector) - self._conn.recv.return_value = proxy.CRLF.join([ - b'GET http://unknown.domain HTTP/1.1', - b'Host: unknown.domain', - proxy.CRLF - ]) - self.proxy.run_once() - self.assertEqual(self.proxy.client.buffer, proxy.ProxyConnectionFailed.RESPONSE_PKT) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_proxy_authentication_failed( - self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - self._conn = mock_fromfd.return_value - self.mock_selector_for_client_read(mock_selector) - config = proxy.ProtocolConfig( - auth_code=b'Basic %s' % - base64.b64encode(b'user:pass')) - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - self._conn.recv.return_value = proxy.CRLF.join([ - b'GET http://abhinavsingh.com HTTP/1.1', - b'Host: abhinavsingh.com', - proxy.CRLF - ]) - self.proxy.run_once() - self.assertEqual( - self.proxy.client.buffer, - proxy.ProxyAuthenticationFailed.RESPONSE_PKT) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - @mock.patch('proxy.TcpServerConnection') - def test_authenticated_proxy_http_get( - self, mock_server_connection: mock.Mock, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - self._conn = mock_fromfd.return_value - self.mock_selector_for_client_read(mock_selector) - - server = mock_server_connection.return_value - server.connect.return_value = True - server.buffer_size.return_value = 0 - - config = proxy.ProtocolConfig( - auth_code=b'Basic %s' % - base64.b64encode(b'user:pass')) - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - - self.proxy = proxy.ProtocolHandler( - self.fileno, addr=self._addr, config=config) - self.proxy.initialize() - assert self.http_server_port is not None - - self._conn.recv.return_value = b'GET http://localhost:%d HTTP/1.1' % self.http_server_port - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.INITIALIZED) - - self._conn.recv.return_value = proxy.CRLF - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.LINE_RCVD) - - assert self.http_server_port is not None - self._conn.recv.return_value = proxy.CRLF.join([ - b'User-Agent: proxy.py/%s' % proxy.version, - b'Host: localhost:%d' % self.http_server_port, - b'Accept: */*', - b'Proxy-Connection: Keep-Alive', - b'Proxy-Authorization: Basic dXNlcjpwYXNz', - proxy.CRLF - ]) - self.assert_data_queued(mock_server_connection, server) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - @mock.patch('proxy.TcpServerConnection') - def test_authenticated_proxy_http_tunnel( - self, mock_server_connection: mock.Mock, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - server = mock_server_connection.return_value - server.connect.return_value = True - server.buffer_size.return_value = 0 - self._conn = mock_fromfd.return_value - self.mock_selector_for_client_read_read_server_write(mock_selector, server) - - config = proxy.ProtocolConfig( - auth_code=b'Basic %s' % - base64.b64encode(b'user:pass')) - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - - assert self.http_server_port is not None - self._conn.recv.return_value = proxy.CRLF.join([ - b'CONNECT localhost:%d HTTP/1.1' % self.http_server_port, - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % proxy.version, - b'Proxy-Connection: Keep-Alive', - b'Proxy-Authorization: Basic dXNlcjpwYXNz', - proxy.CRLF - ]) - self.assert_tunnel_response(mock_server_connection, server) - self.proxy.client.flush() - self.assert_data_queued_to_server(server) - - self.proxy.run_once() - server.flush.assert_called_once() - - def mock_selector_for_client_read_read_server_write(self, mock_selector: mock.Mock, server: mock.Mock) -> None: - mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ], - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=0, - data=None), selectors.EVENT_READ), ], - [(selectors.SelectorKey( - fileobj=server.connection, - fd=server.connection.fileno, - events=0, - data=None), selectors.EVENT_WRITE), ], - ] - - def assert_data_queued( - self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.COMPLETE) - mock_server_connection.assert_called_once() - server.connect.assert_called_once() - server.closed = False - assert self.http_server_port is not None - pkt = proxy.CRLF.join([ - b'GET / HTTP/1.1', - b'User-Agent: proxy.py/%s' % proxy.version, - b'Host: localhost:%d' % self.http_server_port, - b'Accept: */*', - b'Via: %s' % b'1.1 proxy.py v%s' % proxy.version, - proxy.CRLF - ]) - server.queue.assert_called_once_with(pkt) - server.buffer_size.return_value = len(pkt) - - def assert_data_queued_to_server(self, server: mock.Mock) -> None: - assert self.http_server_port is not None - self.assertEqual( - self._conn.send.call_args[0][0], - proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) - - self._conn.recv.return_value = proxy.CRLF.join([ - b'GET / HTTP/1.1', - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % proxy.version, - proxy.CRLF - ]) - self.proxy.run_once() - - pkt = proxy.CRLF.join([ - b'GET / HTTP/1.1', - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % proxy.version, - proxy.CRLF - ]) - server.queue.assert_called_once_with(pkt) - server.buffer_size.return_value = len(pkt) - server.flush.assert_not_called() - - def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: - mock_selector.return_value.select.return_value = [( - selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ] - - -class TestWebServerPlugin(unittest.TestCase): - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self._conn = mock_fromfd.return_value - self.mock_selector = mock_selector - self.config = proxy.ProtocolConfig() - self.config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=self.config) - self.proxy.initialize() - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_pac_file_served_from_disk( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: - pac_file = 'proxy.pac' - self._conn = mock_fromfd.return_value - self.mock_selector_for_client_read(mock_selector) - self.init_and_make_pac_file_request(pac_file) - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.COMPLETE) - with open('proxy.pac', 'rb') as f: - self._conn.send.called_once_with(proxy.build_http_response( - 200, reason=b'OK', headers={ - b'Content-Type': b'application/x-ns-proxy-autoconfig', - b'Connection': b'close' - }, body=f.read() - )) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_pac_file_served_from_buffer( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: - self._conn = mock_fromfd.return_value - self.mock_selector_for_client_read(mock_selector) - pac_file_content = b'function FindProxyForURL(url, host) { return "PROXY localhost:8899; DIRECT"; }' - self.init_and_make_pac_file_request(proxy.text_(pac_file_content)) - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.COMPLETE) - self._conn.send.called_once_with(proxy.build_http_response( - 200, reason=b'OK', headers={ - b'Content-Type': b'application/x-ns-proxy-autoconfig', - b'Connection': b'close' - }, body=pac_file_content - )) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_default_web_server_returns_404( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: - self._conn = mock_fromfd.return_value - mock_selector.return_value.select.return_value = [( - selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ] - config = proxy.ProtocolConfig() - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - self._conn.recv.return_value = proxy.CRLF.join([ - b'GET /hello HTTP/1.1', - proxy.CRLF, - ]) - self.proxy.run_once() - self.assertEqual( - self.proxy.request.state, - proxy.httpParserStates.COMPLETE) - self.assertEqual( - self.proxy.client.buffer, - proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_static_web_server_serves( - self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: - # Setup a static directory - static_server_dir = os.path.join(tempfile.gettempdir(), 'static') - index_file_path = os.path.join(static_server_dir, 'index.html') - html_file_content = b''' - - - - - ''' - os.makedirs(static_server_dir, exist_ok=True) - with open(index_file_path, 'wb') as f: - f.write(html_file_content) - - self._conn = mock_fromfd.return_value - self._conn.recv.return_value = proxy.build_http_request(b'GET', b'/index.html') - - mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_WRITE, - data=None), selectors.EVENT_WRITE)], ] - - config = proxy.ProtocolConfig( - enable_static_server=True, - static_server_dir=static_server_dir) - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - - self.proxy.run_once() - self.proxy.run_once() - - self.assertEqual(mock_selector.return_value.select.call_count, 2) - self.assertEqual(self._conn.send.call_count, 1) - self.assertEqual(self._conn.send.call_args[0][0], proxy.build_http_response( - 200, reason=b'OK', headers={ - b'Content-Type': b'text/html', - b'Content-Length': proxy.bytes_(len(html_file_content)) - }, - body=html_file_content - )) - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_static_web_server_serves_404( - self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - self._conn = mock_fromfd.return_value - self._conn.recv.return_value = proxy.build_http_request(b'GET', b'/not-found.html') - - mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_WRITE, - data=None), selectors.EVENT_WRITE)], ] - - config = proxy.ProtocolConfig(enable_static_server=True) - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin') - - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - - self.proxy.run_once() - self.proxy.run_once() - - self.assertEqual(mock_selector.return_value.select.call_count, 2) - self.assertEqual(self._conn.send.call_count, 1) - self.assertEqual(self._conn.send.call_args[0][0], - proxy.HttpWebServerPlugin.DEFAULT_404_RESPONSE) - - @mock.patch('socket.fromfd') - def test_on_client_connection_called_on_teardown( - self, mock_fromfd: mock.Mock) -> None: - config = proxy.ProtocolConfig() - plugin = mock.MagicMock() - config.plugins = {b'ProtocolHandlerPlugin': [plugin]} - self._conn = mock_fromfd.return_value - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - plugin.assert_called() - with mock.patch.object(self.proxy, 'run_once') as mock_run_once: - mock_run_once.return_value = True - self.proxy.run() - self.assertTrue(self._conn.closed) - plugin.return_value.on_client_connection_close.assert_called() - - def init_and_make_pac_file_request(self, pac_file: str) -> None: - config = proxy.ProtocolConfig(pac_file=pac_file) - config.plugins = proxy.load_plugins( - b'proxy.HttpProxyPlugin,proxy.HttpWebServerPlugin,proxy.HttpWebServerPacFilePlugin') - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=config) - self.proxy.initialize() - self._conn.recv.return_value = proxy.CRLF.join([ - b'GET / HTTP/1.1', - proxy.CRLF, - ]) - - def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: - mock_selector.return_value.select.return_value = [( - selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ), ] - - -class TestHttpProxyPlugin(unittest.TestCase): - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def setUp(self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - self.mock_fromfd = mock_fromfd - self.mock_selector = mock_selector - - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self.config = proxy.ProtocolConfig() - self.plugin = mock.MagicMock() - self.config.plugins = { - b'ProtocolHandlerPlugin': [proxy.HttpProxyPlugin], - b'HttpProxyBasePlugin': [self.plugin] - } - self._conn = mock_fromfd.return_value - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=self.config) - self.proxy.initialize() - - def test_proxy_plugin_initialized(self) -> None: - self.plugin.assert_called() - - @mock.patch('proxy.TcpServerConnection') - def test_proxy_plugin_on_and_before_upstream_connection( - self, - mock_server_conn: mock.Mock) -> None: - self.plugin.return_value.before_upstream_connection.side_effect = lambda r: r - self.plugin.return_value.handle_client_request.side_effect = lambda r: r - - self._conn.recv.return_value = proxy.build_http_request( - b'GET', b'http://upstream.host/not-found.html', - headers={ - b'Host': b'upstream.host' - }) - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - - self.proxy.run_once() - mock_server_conn.assert_called_with('upstream.host', 80) - self.plugin.return_value.before_upstream_connection.assert_called() - self.plugin.return_value.handle_client_request.assert_called() - - @mock.patch('proxy.TcpServerConnection') - def test_proxy_plugin_before_upstream_connection_can_teardown( - self, - mock_server_conn: mock.Mock) -> None: - self.plugin.return_value.before_upstream_connection.side_effect = proxy.ProtocolException() - - self._conn.recv.return_value = proxy.build_http_request( - b'GET', b'http://upstream.host/not-found.html', - headers={ - b'Host': b'upstream.host' - }) - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - - self.proxy.run_once() - self.plugin.return_value.before_upstream_connection.assert_called() - mock_server_conn.assert_not_called() - - -class TestHttpProxyPluginExamples(unittest.TestCase): - - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def setUp(self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock) -> None: - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self.config = proxy.ProtocolConfig() - self.plugin = mock.MagicMock() - - self.mock_fromfd = mock_fromfd - self.mock_selector = mock_selector - - plugin = get_plugin_by_test_name(self._testMethodName) - - self.config.plugins = { - b'ProtocolHandlerPlugin': [proxy.HttpProxyPlugin], - b'HttpProxyBasePlugin': [plugin], - } - self._conn = mock_fromfd.return_value - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=self.config) - self.proxy.initialize() - - @mock.patch('proxy.TcpServerConnection') - def test_modify_post_data_plugin(self, mock_server_conn: mock.Mock) -> None: - original = b'{"key": "value"}' - modified = b'{"key": "modified"}' - - self._conn.recv.return_value = proxy.build_http_request( - b'POST', b'http://httpbin.org/post', - headers={ - b'Host': b'httpbin.org', - b'Content-Type': b'application/x-www-form-urlencoded', - b'Content-Length': proxy.bytes_(len(original)), - }, - body=original - ) - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - - self.proxy.run_once() - mock_server_conn.assert_called_with('httpbin.org', 80) - mock_server_conn.return_value.queue.assert_called_with( - proxy.build_http_request( - b'POST', b'/post', - headers={ - b'Host': b'httpbin.org', - b'Content-Length': proxy.bytes_(len(modified)), - b'Content-Type': b'application/json', - b'Via': b'1.1 %s' % proxy.PROXY_AGENT_HEADER_VALUE, - }, - body=modified - ) - ) - - @mock.patch('proxy.TcpServerConnection') - def test_proposed_rest_api_plugin( - self, mock_server_conn: mock.Mock) -> None: - path = b'/v1/users/' - self._conn.recv.return_value = proxy.build_http_request( - b'GET', b'http://%s%s' % (plugin_examples.ProposedRestApiPlugin.API_SERVER, path), - headers={ - b'Host': plugin_examples.ProposedRestApiPlugin.API_SERVER, - } - ) - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - self.proxy.run_once() - - mock_server_conn.assert_not_called() - self.assertEqual( - self.proxy.client.buffer, - proxy.build_http_response( - proxy.httpStatusCodes.OK, reason=b'OK', - headers={b'Content-Type': b'application/json'}, - body=proxy.bytes_(json.dumps(plugin_examples.ProposedRestApiPlugin.REST_API_SPEC[path])) - )) - - @mock.patch('proxy.TcpServerConnection') - def test_redirect_to_custom_server_plugin( - self, mock_server_conn: mock.Mock) -> None: - request = proxy.build_http_request( - b'GET', b'http://example.org/get', - headers={ - b'Host': b'example.org', - } - ) - self._conn.recv.return_value = request - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - self.proxy.run_once() - - upstream = urlparse.urlsplit( - plugin_examples.RedirectToCustomServerPlugin.UPSTREAM_SERVER) - mock_server_conn.assert_called_with('localhost', 8899) - mock_server_conn.return_value.queue.assert_called_with( - proxy.build_http_request( - b'GET', upstream.path, - headers={ - b'Host': upstream.netloc, - b'Via': b'1.1 %s' % proxy.PROXY_AGENT_HEADER_VALUE, - } - ) - ) - - @mock.patch('proxy.TcpServerConnection') - def test_filter_by_upstream_host_plugin( - self, mock_server_conn: mock.Mock) -> None: - request = proxy.build_http_request( - b'GET', b'http://google.com/', - headers={ - b'Host': b'google.com', - } - ) - self._conn.recv.return_value = request - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - self.proxy.run_once() - - mock_server_conn.assert_not_called() - self.assertEqual( - self.proxy.client.buffer, - proxy.build_http_response( - proxy.httpStatusCodes.I_AM_A_TEAPOT, - reason=b'I\'m a tea pot', - headers={ - proxy.PROXY_AGENT_HEADER_KEY: proxy.PROXY_AGENT_HEADER_VALUE - }, - ) - ) - - @mock.patch('proxy.TcpServerConnection') - def test_man_in_the_middle_plugin( - self, mock_server_conn: mock.Mock) -> None: - request = proxy.build_http_request( - b'GET', b'http://super.secure/', - headers={ - b'Host': b'super.secure', - } - ) - self._conn.recv.return_value = request - - server = mock_server_conn.return_value - server.connect.return_value = True - - def has_buffer() -> bool: - return cast(bool, server.queue.called) - - def closed() -> bool: - return not server.connect.called - - server.has_buffer.side_effect = has_buffer - type(server).closed = mock.PropertyMock(side_effect=closed) - - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=server.connection, - fd=server.connection.fileno, - events=selectors.EVENT_WRITE, - data=None), selectors.EVENT_WRITE)], - [(selectors.SelectorKey( - fileobj=server.connection, - fd=server.connection.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - - # Client read - self.proxy.run_once() - mock_server_conn.assert_called_with('super.secure', 80) - server.connect.assert_called_once() - queued_request = \ - proxy.build_http_request( - b'GET', b'/', - headers={ - b'Host': b'super.secure', - b'Via': b'1.1 %s' % proxy.PROXY_AGENT_HEADER_VALUE - } - ) - server.queue.assert_called_once_with(queued_request) - - # Server write - self.proxy.run_once() - server.flush.assert_called_once() - - # Server read - server.recv.return_value = \ - proxy.build_http_response( - proxy.httpStatusCodes.OK, - reason=b'OK', body=b'Original Response From Upstream') - self.proxy.run_once() - self.assertEqual( - self.proxy.client.buffer, - proxy.build_http_response( - proxy.httpStatusCodes.OK, - reason=b'OK', body=b'Hello from man in the middle') - ) - - -class TestHttpProxyTlsInterception(unittest.TestCase): - - @mock.patch('ssl.wrap_socket') - @mock.patch('ssl.create_default_context') - @mock.patch('proxy.TcpServerConnection') - @mock.patch('subprocess.Popen') - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def test_e2e( - self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_popen: mock.Mock, - mock_server_conn: mock.Mock, - mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock) -> None: - host, port = uuid.uuid4().hex, 443 - netloc = '{0}:{1}'.format(host, port) - - self.mock_fromfd = mock_fromfd - self.mock_selector = mock_selector - self.mock_popen = mock_popen - self.mock_server_conn = mock_server_conn - self.mock_ssl_context = mock_ssl_context - self.mock_ssl_wrap = mock_ssl_wrap - - ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) - self.mock_ssl_context.return_value.wrap_socket.return_value = ssl_connection - self.mock_ssl_wrap.return_value = mock.MagicMock(spec=ssl.SSLSocket) - plain_connection = mock.MagicMock(spec=socket.socket) - - def mock_connection() -> Any: - if self.mock_ssl_context.return_value.wrap_socket.called: - return ssl_connection - return plain_connection - - type(self.mock_server_conn.return_value).connection = \ - mock.PropertyMock(side_effect=mock_connection) - - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self.config = proxy.ProtocolConfig( - ca_cert_file='ca-cert.pem', - ca_key_file='ca-key.pem', - ca_signing_key_file='ca-signing-key.pem', - ) - self.plugin = mock.MagicMock() - self.proxy_plugin = mock.MagicMock() - self.config.plugins = { - b'ProtocolHandlerPlugin': [self.plugin, proxy.HttpProxyPlugin], - b'HttpProxyBasePlugin': [self.proxy_plugin], - } - self._conn = mock_fromfd.return_value - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=self.config) - self.proxy.initialize() - - self.plugin.assert_called() - self.assertEqual(self.plugin.call_args[0][0], self.config) - self.assertEqual(self.plugin.call_args[0][1].connection, self._conn) - self.proxy_plugin.assert_called() - self.assertEqual(self.proxy_plugin.call_args[0][0], self.config) - self.assertEqual(self.proxy_plugin.call_args[0][1].connection, self._conn) - - connect_request = proxy.build_http_request( - proxy.httpMethods.CONNECT, proxy.bytes_(netloc), - headers={ - b'Host': proxy.bytes_(netloc), - }) - self._conn.recv.return_value = connect_request - - # Prepare mocked ProtocolHandlerPlugin - self.plugin.return_value.get_descriptors.return_value = ([], []) - self.plugin.return_value.write_to_descriptors.return_value = False - self.plugin.return_value.read_from_descriptors.return_value = False - self.plugin.return_value.on_client_data.side_effect = lambda raw: raw - self.plugin.return_value.on_request_complete.return_value = False - self.plugin.return_value.on_response_chunk.side_effect = lambda chunk: chunk - self.plugin.return_value.on_client_connection_close.return_value = None - - # Prepare mocked HttpProxyBasePlugin - self.proxy_plugin.return_value.before_upstream_connection.side_effect = lambda r: r - self.proxy_plugin.return_value.handle_client_request.side_effect = lambda r: r - - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - self.proxy.run_once() - - # Assert our mocked plugins invocations - self.plugin.return_value.get_descriptors.assert_called() - self.plugin.return_value.write_to_descriptors.assert_called_with([]) - self.plugin.return_value.on_client_data.assert_called_with(connect_request) - self.plugin.return_value.on_request_complete.assert_called() - self.plugin.return_value.read_from_descriptors.assert_called_with([self._conn]) - self.proxy_plugin.return_value.before_upstream_connection.assert_called() - self.proxy_plugin.return_value.handle_client_request.assert_called() - - self.mock_server_conn.assert_called_with(host, port) - self.mock_server_conn.return_value.connection.setblocking.assert_called_with(False) - - self.mock_ssl_context.assert_called_with(ssl.Purpose.SERVER_AUTH) - # self.assertEqual(self.mock_ssl_context.return_value.options, - # ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1) - self.assertEqual(plain_connection.setblocking.call_count, 2) - self.mock_ssl_context.return_value.wrap_socket.assert_called_with( - plain_connection, server_hostname=host) - # TODO: Assert Popen arguments, piping, success condition - self.assertEqual(self.mock_popen.call_count, 2) - self.assertEqual(ssl_connection.setblocking.call_count, 1) - self.assertEqual(self.mock_server_conn.return_value._conn, ssl_connection) - self._conn.send.assert_called_with(proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) - assert self.config.ca_cert_dir is not None - self.mock_ssl_wrap.assert_called_with( - self._conn, - server_side=True, - keyfile=self.config.ca_signing_key_file, - certfile=proxy.HttpProxyPlugin.generated_cert_file_path( - self.config.ca_cert_dir, host) - ) - self.assertEqual(self._conn.setblocking.call_count, 2) - self.assertEqual(self.proxy.client.connection, self.mock_ssl_wrap.return_value) - - # Assert connection references for all other plugins is updated - self.assertEqual(self.plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) - self.assertEqual(self.proxy_plugin.return_value.client._conn, self.mock_ssl_wrap.return_value) - - -class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): - - @mock.patch('ssl.wrap_socket') - @mock.patch('ssl.create_default_context') - @mock.patch('proxy.TcpServerConnection') - @mock.patch('subprocess.Popen') - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def setUp(self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_popen: mock.Mock, - mock_server_conn: mock.Mock, - mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock) -> None: - self.mock_fromfd = mock_fromfd - self.mock_selector = mock_selector - self.mock_popen = mock_popen - self.mock_server_conn = mock_server_conn - self.mock_ssl_context = mock_ssl_context - self.mock_ssl_wrap = mock_ssl_wrap - - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self.config = proxy.ProtocolConfig( - ca_cert_file='ca-cert.pem', - ca_key_file='ca-key.pem', - ca_signing_key_file='ca-signing-key.pem',) - self.plugin = mock.MagicMock() - - plugin = get_plugin_by_test_name(self._testMethodName) - - self.config.plugins = { - b'ProtocolHandlerPlugin': [proxy.HttpProxyPlugin], - b'HttpProxyBasePlugin': [plugin], - } - self._conn = mock.MagicMock(spec=socket.socket) - mock_fromfd.return_value = self._conn - self.proxy = proxy.ProtocolHandler( - self.fileno, self._addr, config=self.config) - self.proxy.initialize() - - self.server = self.mock_server_conn.return_value - - self.server_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) - self.mock_ssl_context.return_value.wrap_socket.return_value = self.server_ssl_connection - self.client_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) - self.mock_ssl_wrap.return_value = self.client_ssl_connection - - def has_buffer() -> bool: - return cast(bool, self.server.queue.called) - - def closed() -> bool: - return not self.server.connect.called - - def mock_connection() -> Any: - if self.mock_ssl_context.return_value.wrap_socket.called: - return self.server_ssl_connection - return self._conn - - self.server.has_buffer.side_effect = has_buffer - type(self.server).closed = mock.PropertyMock(side_effect=closed) - type(self.server).connection = mock.PropertyMock(side_effect=mock_connection) - - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=self.client_ssl_connection, - fd=self.client_ssl_connection.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=self.server_ssl_connection, - fd=self.server_ssl_connection.fileno, - events=selectors.EVENT_WRITE, - data=None), selectors.EVENT_WRITE)], - [(selectors.SelectorKey( - fileobj=self.server_ssl_connection, - fd=self.server_ssl_connection.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - - # Connect - def send(raw: bytes) -> int: - return len(raw) - - self._conn.send.side_effect = send - self._conn.recv.return_value = proxy.build_http_request( - proxy.httpMethods.CONNECT, b'uni.corn:443' - ) - self.proxy.run_once() - - self.mock_popen.assert_called() - self.mock_server_conn.assert_called_once_with('uni.corn', 443) - self.server.connect.assert_called() - self.assertEqual(self.proxy.client.connection, self.client_ssl_connection) - self.assertEqual(self.server.connection, self.server_ssl_connection) - self._conn.send.assert_called_with( - proxy.HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT - ) - self.assertEqual(self.proxy.client.buffer, b'') - - def test_modify_post_data_plugin(self) -> None: - original = b'{"key": "value"}' - modified = b'{"key": "modified"}' - self.client_ssl_connection.recv.return_value = proxy.build_http_request( - b'POST', b'/', - headers={ - b'Host': b'uni.corn', - b'Content-Type': b'application/x-www-form-urlencoded', - b'Content-Length': proxy.bytes_(len(original)), - }, - body=original - ) - self.proxy.run_once() - self.server.queue.assert_called_with( - proxy.build_http_request( - b'POST', b'/', - headers={ - b'Host': b'uni.corn', - b'Content-Length': proxy.bytes_(len(modified)), - b'Content-Type': b'application/json', - }, - body=modified - ) - ) - - @mock.patch('proxy.TcpServerConnection') - def test_man_in_the_middle_plugin( - self, mock_server_conn: mock.Mock) -> None: - request = proxy.build_http_request( - b'GET', b'/', - headers={ - b'Host': b'uni.corn', - } - ) - self.client_ssl_connection.recv.return_value = request - - # Client read - self.proxy.run_once() - self.server.queue.assert_called_once_with(request) - - # Server write - self.proxy.run_once() - self.server.flush.assert_called_once() - - # Server read - self.server.recv.return_value = \ - proxy.build_http_response( - proxy.httpStatusCodes.OK, - reason=b'OK', body=b'Original Response From Upstream') - self.proxy.run_once() - self.assertEqual( - self.proxy.client.buffer, - proxy.build_http_response( - proxy.httpStatusCodes.OK, - reason=b'OK', body=b'Hello from man in the middle') - ) - - -class TestHttpRequestRejected(unittest.TestCase): - - def setUp(self) -> None: - self.request = proxy.HttpParser(proxy.httpParserTypes.REQUEST_PARSER) - - def test_empty_response(self) -> None: - e = proxy.HttpRequestRejected() - self.assertEqual(e.response(self.request), None) - - def test_status_code_response(self) -> None: - e = proxy.HttpRequestRejected(status_code=200, reason=b'OK') - self.assertEqual(e.response(self.request), proxy.CRLF.join([ - b'HTTP/1.1 200 OK', - proxy.PROXY_AGENT_HEADER, - proxy.CRLF - ])) - - def test_body_response(self) -> None: - e = proxy.HttpRequestRejected( - status_code=404, reason=b'NOT FOUND', - body=b'Nothing here') - self.assertEqual(e.response(self.request), proxy.CRLF.join([ - b'HTTP/1.1 404 NOT FOUND', - proxy.PROXY_AGENT_HEADER, - b'Content-Length: 12', - proxy.CRLF, - b'Nothing here' - ])) - - -class TestMain(unittest.TestCase): - - @staticmethod - def mock_default_args(mock_args: mock.Mock) -> None: - mock_args.version = False - mock_args.cert_file = proxy.DEFAULT_CERT_FILE - mock_args.key_file = proxy.DEFAULT_KEY_FILE - mock_args.ca_key_file = proxy.DEFAULT_CA_KEY_FILE - mock_args.ca_cert_file = proxy.DEFAULT_CA_CERT_FILE - mock_args.ca_signing_key_file = proxy.DEFAULT_CA_SIGNING_KEY_FILE - mock_args.pid_file = proxy.DEFAULT_PID_FILE - mock_args.log_file = proxy.DEFAULT_LOG_FILE - mock_args.log_level = proxy.DEFAULT_LOG_LEVEL - mock_args.log_format = proxy.DEFAULT_LOG_FORMAT - mock_args.basic_auth = proxy.DEFAULT_BASIC_AUTH - mock_args.hostname = proxy.DEFAULT_IPV6_HOSTNAME - mock_args.port = proxy.DEFAULT_PORT - mock_args.num_workers = proxy.DEFAULT_NUM_WORKERS - mock_args.disable_http_proxy = proxy.DEFAULT_DISABLE_HTTP_PROXY - mock_args.enable_web_server = proxy.DEFAULT_ENABLE_WEB_SERVER - mock_args.pac_file = proxy.DEFAULT_PAC_FILE - mock_args.plugins = proxy.DEFAULT_PLUGINS - mock_args.server_recvbuf_size = proxy.DEFAULT_SERVER_RECVBUF_SIZE - mock_args.client_recvbuf_size = proxy.DEFAULT_CLIENT_RECVBUF_SIZE - mock_args.open_file_limit = proxy.DEFAULT_OPEN_FILE_LIMIT - mock_args.enable_static_server = proxy.DEFAULT_ENABLE_STATIC_SERVER - mock_args.enable_devtools = proxy.DEFAULT_ENABLE_DEVTOOLS - mock_args.devtools_event_queue = None - mock_args.devtools_ws_path = proxy.DEFAULT_DEVTOOLS_WS_PATH - mock_args.timeout = proxy.DEFAULT_TIMEOUT - mock_args.threadless = proxy.DEFAULT_THREADLESS - - @mock.patch('time.sleep') - @mock.patch('proxy.load_plugins') - @mock.patch('proxy.init_parser') - @mock.patch('proxy.set_open_file_limit') - @mock.patch('proxy.ProtocolConfig') - @mock.patch('proxy.AcceptorPool') - @mock.patch('proxy.logging.basicConfig') - def test_init_with_no_arguments( - self, - mock_logging_config: mock.Mock, - mock_acceptor_pool: mock.Mock, - mock_protocol_config: mock.Mock, - mock_set_open_file_limit: mock.Mock, - mock_init_parser: mock.Mock, - mock_load_plugins: mock.Mock, - mock_sleep: mock.Mock) -> None: - mock_sleep.side_effect = KeyboardInterrupt() - - mock_args = mock_init_parser.return_value.parse_args.return_value - self.mock_default_args(mock_args) - proxy.main([]) - - mock_init_parser.assert_called() - mock_init_parser.return_value.parse_args.called_with([]) - - mock_load_plugins.assert_called_with(b'proxy.HttpProxyPlugin,') - mock_logging_config.assert_called_with( - level=logging.INFO, - format=proxy.DEFAULT_LOG_FORMAT - ) - mock_set_open_file_limit.assert_called_with(mock_args.open_file_limit) - - mock_protocol_config.assert_called_with( - auth_code=mock_args.basic_auth, - backlog=mock_args.backlog, - ca_cert_dir=mock_args.ca_cert_dir, - ca_cert_file=mock_args.ca_cert_file, - ca_key_file=mock_args.ca_key_file, - ca_signing_key_file=mock_args.ca_signing_key_file, - certfile=mock_args.cert_file, - client_recvbuf_size=mock_args.client_recvbuf_size, - hostname=mock_args.hostname, - keyfile=mock_args.key_file, - num_workers=multiprocessing.cpu_count(), - pac_file=mock_args.pac_file, - pac_file_url_path=mock_args.pac_file_url_path, - port=mock_args.port, - server_recvbuf_size=mock_args.server_recvbuf_size, - disable_headers=[ - header.lower() for header in proxy.bytes_( - mock_args.disable_headers).split(proxy.COMMA) if header.strip() != b''], - static_server_dir=mock_args.static_server_dir, - enable_static_server=mock_args.enable_static_server, - devtools_event_queue=None, - devtools_ws_path=proxy.DEFAULT_DEVTOOLS_WS_PATH, - timeout=proxy.DEFAULT_TIMEOUT, - threadless=proxy.DEFAULT_THREADLESS, - ) - - mock_acceptor_pool.assert_called_with( - hostname=mock_protocol_config.return_value.hostname, - port=mock_protocol_config.return_value.port, - backlog=mock_protocol_config.return_value.backlog, - num_workers=mock_protocol_config.return_value.num_workers, - work_klass=proxy.ProtocolHandler, - threadless=mock_protocol_config.return_value.threadless, - config=mock_protocol_config.return_value, - ) - mock_acceptor_pool.return_value.setup.assert_called() - mock_acceptor_pool.return_value.shutdown.assert_called() - mock_sleep.assert_called_with(1) - - @mock.patch('time.sleep') - @mock.patch('os.remove') - @mock.patch('os.path.exists') - @mock.patch('builtins.open') - @mock.patch('proxy.init_parser') - @mock.patch('proxy.AcceptorPool') - def test_pid_file_is_written_and_removed( - self, - mock_acceptor_pool: mock.Mock, - mock_init_parser: mock.Mock, - mock_open: mock.Mock, - mock_exists: mock.Mock, - mock_remove: mock.Mock, - mock_sleep: mock.Mock) -> None: - pid_file = get_temp_file('proxy.pid') - mock_sleep.side_effect = KeyboardInterrupt() - mock_args = mock_init_parser.return_value.parse_args.return_value - self.mock_default_args(mock_args) - mock_args.pid_file = pid_file - proxy.main(['--pid-file', pid_file]) - mock_init_parser.assert_called() - mock_acceptor_pool.assert_called() - mock_acceptor_pool.return_value.setup.assert_called() - mock_open.assert_called_with(pid_file, 'wb') - mock_open.return_value.__enter__.return_value.write.assert_called_with( - proxy.bytes_(os.getpid())) - mock_exists.assert_called_with(pid_file) - mock_remove.assert_called_with(pid_file) - - @mock.patch('time.sleep') - @mock.patch('proxy.ProtocolConfig') - @mock.patch('proxy.AcceptorPool') - def test_basic_auth( - self, - mock_acceptor_pool: mock.Mock, - mock_protocol_config: mock.Mock, - mock_sleep: mock.Mock) -> None: - mock_sleep.side_effect = KeyboardInterrupt() - proxy.main(['--basic-auth', 'user:pass']) - config = mock_protocol_config.return_value - mock_acceptor_pool.assert_called_with( - hostname=config.hostname, - port=config.port, - backlog=config.backlog, - num_workers=config.num_workers, - work_klass=proxy.ProtocolHandler, - threadless=config.threadless, - config=config) - self.assertEqual(mock_protocol_config.call_args[1]['auth_code'], b'Basic dXNlcjpwYXNz') - - @mock.patch('builtins.print') - def test_main_version( - self, - mock_print: mock.Mock) -> None: - with self.assertRaises(SystemExit): - proxy.main(['--version']) - mock_print.assert_called_with(proxy.text_(proxy.version)) - - @mock.patch('time.sleep') - @mock.patch('builtins.print') - @mock.patch('proxy.AcceptorPool') - @mock.patch('proxy.is_py3') - def test_main_py3_runs( - self, - mock_is_py3: mock.Mock, - mock_acceptor_pool: mock.Mock, - mock_print: mock.Mock, - mock_sleep: mock.Mock) -> None: - mock_sleep.side_effect = KeyboardInterrupt() - mock_is_py3.return_value = True - proxy.main([]) - mock_is_py3.assert_called() - mock_print.assert_not_called() - mock_acceptor_pool.assert_called() - mock_acceptor_pool.return_value.setup.assert_called() - - @mock.patch('builtins.print') - @mock.patch('proxy.is_py3') - def test_main_py2_exit( - self, - mock_is_py3: mock.Mock, - mock_print: mock.Mock) -> None: - proxy.UNDER_TEST = False - mock_is_py3.return_value = False - with self.assertRaises(SystemExit): - proxy.main([]) - mock_print.assert_called_with('DEPRECATION') - mock_is_py3.assert_called() - - -@unittest.skipIf( - os.name == 'nt', - 'Open file limit tests disabled for Windows') -class TestSetOpenFileLimit(unittest.TestCase): - - @mock.patch('resource.getrlimit', return_value=(128, 1024)) - @mock.patch('resource.setrlimit', return_value=None) - def test_set_open_file_limit( - self, - mock_set_rlimit: mock.Mock, - mock_get_rlimit: mock.Mock) -> None: - proxy.set_open_file_limit(256) - mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) - mock_set_rlimit.assert_called_with(resource.RLIMIT_NOFILE, (256, 1024)) - - @mock.patch('resource.getrlimit', return_value=(256, 1024)) - @mock.patch('resource.setrlimit', return_value=None) - def test_set_open_file_limit_not_called( - self, - mock_set_rlimit: mock.Mock, - mock_get_rlimit: mock.Mock) -> None: - proxy.set_open_file_limit(256) - mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) - mock_set_rlimit.assert_not_called() - - @mock.patch('resource.getrlimit', return_value=(256, 1024)) - @mock.patch('resource.setrlimit', return_value=None) - def test_set_open_file_limit_not_called_coz_upper_bound_check( - self, - mock_set_rlimit: mock.Mock, - mock_get_rlimit: mock.Mock) -> None: - proxy.set_open_file_limit(1024) - mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) - mock_set_rlimit.assert_not_called() - - -if __name__ == '__main__': - proxy.UNDER_TEST = True - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..3fd4cfafd4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging + +from proxy.common.constants import DEFAULT_LOG_FORMAT + +logging.basicConfig(level=logging.DEBUG, format=DEFAULT_LOG_FORMAT) diff --git a/tests/test_acceptor.py b/tests/test_acceptor.py new file mode 100644 index 0000000000..b539aab0d8 --- /dev/null +++ b/tests/test_acceptor.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import socket +import selectors +import multiprocessing +from unittest import mock + +from proxy.common.flags import Flags +from proxy.core.acceptor import Acceptor, AcceptorPool + + +class TestAcceptor(unittest.TestCase): + + def setUp(self) -> None: + self.acceptor_id = 1 + self.mock_protocol_handler = mock.MagicMock() + self.pipe = multiprocessing.Pipe() + self.flags = Flags() + self.acceptor = Acceptor( + idd=self.acceptor_id, + work_queue=self.pipe[1], + flags=self.flags, + work_klass=self.mock_protocol_handler) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + @mock.patch('proxy.core.acceptor.recv_handle') + def test_continues_when_no_events( + self, + mock_recv_handle: mock.Mock, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + fileno = 10 + conn = mock.MagicMock() + addr = mock.MagicMock() + sock = mock_fromfd.return_value + mock_fromfd.return_value.accept.return_value = (conn, addr) + mock_recv_handle.return_value = fileno + + selector = mock_selector.return_value + selector.select.side_effect = [[], KeyboardInterrupt()] + + self.acceptor.run() + + sock.accept.assert_not_called() + self.mock_protocol_handler.assert_not_called() + + @mock.patch('threading.Thread') + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + @mock.patch('proxy.core.acceptor.recv_handle') + def test_accepts_client_from_server_socket( + self, + mock_recv_handle: mock.Mock, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_thread: mock.Mock) -> None: + fileno = 10 + conn = mock.MagicMock() + addr = mock.MagicMock() + sock = mock_fromfd.return_value + mock_fromfd.return_value.accept.return_value = (conn, addr) + mock_recv_handle.return_value = fileno + + mock_thread.return_value.start.side_effect = KeyboardInterrupt() + + selector = mock_selector.return_value + selector.select.return_value = [(None, None)] + + self.acceptor.run() + + selector.register.assert_called_with(sock, selectors.EVENT_READ) + selector.unregister.assert_called_with(sock) + mock_recv_handle.assert_called_with(self.pipe[1]) + mock_fromfd.assert_called_with( + fileno, + family=socket.AF_INET6, + type=socket.SOCK_STREAM + ) + self.mock_protocol_handler.assert_called_with( + fileno=conn.fileno(), + addr=addr, + flags=self.flags, + event_queue=None, + ) + mock_thread.assert_called_with( + target=self.mock_protocol_handler.return_value.run) + mock_thread.return_value.start.assert_called() + sock.close.assert_called() + + +class TestAcceptorPool(unittest.TestCase): + + @mock.patch('proxy.core.acceptor.send_handle') + @mock.patch('multiprocessing.Pipe') + @mock.patch('socket.socket') + @mock.patch('proxy.core.acceptor.Acceptor') + def test_setup_and_shutdown( + self, + mock_worker: mock.Mock, + mock_socket: mock.Mock, + mock_pipe: mock.Mock, + _mock_send_handle: mock.Mock) -> None: + mock_worker1 = mock.MagicMock() + mock_worker2 = mock.MagicMock() + mock_worker.side_effect = [mock_worker1, mock_worker2] + + num_workers = 2 + sock = mock_socket.return_value + work_klass = mock.MagicMock() + flags = Flags(num_workers=2) + acceptor = AcceptorPool(flags=flags, work_klass=work_klass) + + acceptor.setup() + + work_klass.assert_not_called() + mock_socket.assert_called_with( + socket.AF_INET6 if acceptor.flags.hostname.version == 6 else socket.AF_INET, + socket.SOCK_STREAM + ) + sock.setsockopt.assert_called_with( + socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind.assert_called_with( + (str(acceptor.flags.hostname), acceptor.flags.port)) + sock.listen.assert_called_with(acceptor.flags.backlog) + sock.setblocking.assert_called_with(False) + + self.assertTrue(mock_pipe.call_count, num_workers) + self.assertTrue(mock_worker.call_count, num_workers) + mock_worker1.start.assert_called() + mock_worker1.join.assert_not_called() + mock_worker2.start.assert_called() + mock_worker2.join.assert_not_called() + + sock.close.assert_called() + + acceptor.shutdown() + mock_worker1.join.assert_called() + mock_worker2.join.assert_called() diff --git a/tests/test_chunk_parser.py b/tests/test_chunk_parser.py new file mode 100644 index 0000000000..da4018af03 --- /dev/null +++ b/tests/test_chunk_parser.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest + +from proxy.http.chunk_parser import chunkParserStates, ChunkParser + + +class TestChunkParser(unittest.TestCase): + + def setUp(self) -> None: + self.parser = ChunkParser() + + def test_chunk_parse_basic(self) -> None: + self.parser.parse(b''.join([ + b'4\r\n', + b'Wiki\r\n', + b'5\r\n', + b'pedia\r\n', + b'E\r\n', + b' in\r\n\r\nchunks.\r\n', + b'0\r\n', + b'\r\n' + ])) + self.assertEqual(self.parser.chunk, b'') + self.assertEqual(self.parser.size, None) + self.assertEqual(self.parser.body, b'Wikipedia in\r\n\r\nchunks.') + self.assertEqual(self.parser.state, chunkParserStates.COMPLETE) + + def test_chunk_parse_issue_27(self) -> None: + """Case when data ends with the chunk size but without ending CRLF.""" + self.parser.parse(b'3') + self.assertEqual(self.parser.chunk, b'3') + self.assertEqual(self.parser.size, None) + self.assertEqual(self.parser.body, b'') + self.assertEqual( + self.parser.state, + chunkParserStates.WAITING_FOR_SIZE) + self.parser.parse(b'\r\n') + self.assertEqual(self.parser.chunk, b'') + self.assertEqual(self.parser.size, 3) + self.assertEqual(self.parser.body, b'') + self.assertEqual( + self.parser.state, + chunkParserStates.WAITING_FOR_DATA) + self.parser.parse(b'abc') + self.assertEqual(self.parser.chunk, b'') + self.assertEqual(self.parser.size, None) + self.assertEqual(self.parser.body, b'abc') + self.assertEqual( + self.parser.state, + chunkParserStates.WAITING_FOR_SIZE) + self.parser.parse(b'\r\n') + self.assertEqual(self.parser.chunk, b'') + self.assertEqual(self.parser.size, None) + self.assertEqual(self.parser.body, b'abc') + self.assertEqual( + self.parser.state, + chunkParserStates.WAITING_FOR_SIZE) + self.parser.parse(b'4\r\n') + self.assertEqual(self.parser.chunk, b'') + self.assertEqual(self.parser.size, 4) + self.assertEqual(self.parser.body, b'abc') + self.assertEqual( + self.parser.state, + chunkParserStates.WAITING_FOR_DATA) + self.parser.parse(b'defg\r\n0') + self.assertEqual(self.parser.chunk, b'0') + self.assertEqual(self.parser.size, None) + self.assertEqual(self.parser.body, b'abcdefg') + self.assertEqual( + self.parser.state, + chunkParserStates.WAITING_FOR_SIZE) + self.parser.parse(b'\r\n\r\n') + self.assertEqual(self.parser.chunk, b'') + self.assertEqual(self.parser.size, None) + self.assertEqual(self.parser.body, b'abcdefg') + self.assertEqual(self.parser.state, chunkParserStates.COMPLETE) + + def test_to_chunks(self) -> None: + self.assertEqual( + b'f\r\n{"key":"value"}\r\n0\r\n\r\n', + ChunkParser.to_chunks(b'{"key":"value"}')) diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000000..2389f6e75c --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import socket +import ssl +from unittest import mock +from typing import Optional, Union + +from proxy.core.connection import tcpConnectionTypes, TcpConnectionUninitializedException +from proxy.core.connection import TcpServerConnection, TcpConnection, TcpClientConnection +from proxy.common.constants import DEFAULT_IPV6_HOSTNAME, DEFAULT_PORT, DEFAULT_IPV4_HOSTNAME + + +class TestTcpConnection(unittest.TestCase): + class TcpConnectionToTest(TcpConnection): + + def __init__(self, conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None, + tag: int = tcpConnectionTypes.CLIENT) -> None: + super().__init__(tag) + self._conn = conn + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + if self._conn is None: + raise TcpConnectionUninitializedException() + return self._conn + + def testThrowsKeyErrorIfNoConn(self) -> None: + self.conn = TestTcpConnection.TcpConnectionToTest() + with self.assertRaises(TcpConnectionUninitializedException): + self.conn.send(b'dummy') + with self.assertRaises(TcpConnectionUninitializedException): + self.conn.recv() + with self.assertRaises(TcpConnectionUninitializedException): + self.conn.close() + + def testClosesIfNotClosed(self) -> None: + _conn = mock.MagicMock() + self.conn = TestTcpConnection.TcpConnectionToTest(_conn) + self.conn.close() + _conn.close.assert_called() + self.assertTrue(self.conn.closed) + + def testNoOpIfAlreadyClosed(self) -> None: + _conn = mock.MagicMock() + self.conn = TestTcpConnection.TcpConnectionToTest(_conn) + self.conn.closed = True + self.conn.close() + _conn.close.assert_not_called() + self.assertTrue(self.conn.closed) + + def testFlushReturnsIfNoBuffer(self) -> None: + _conn = mock.MagicMock() + self.conn = TestTcpConnection.TcpConnectionToTest(_conn) + self.conn.flush() + self.assertTrue(not _conn.send.called) + + @mock.patch('socket.socket') + def testTcpServerEstablishesIPv6Connection( + self, mock_socket: mock.Mock) -> None: + conn = TcpServerConnection( + str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT) + conn.connect() + mock_socket.assert_called() + mock_socket.return_value.connect.assert_called_with( + (str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT, 0, 0)) + + @mock.patch('proxy.core.connection.new_socket_connection') + def testTcpServerIgnoresDoubleConnectSilently( + self, + mock_new_socket_connection: mock.Mock) -> None: + conn = TcpServerConnection( + str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT) + conn.connect() + conn.connect() + mock_new_socket_connection.assert_called_once() + + @mock.patch('socket.socket') + def testTcpServerEstablishesIPv4Connection( + self, mock_socket: mock.Mock) -> None: + conn = TcpServerConnection( + str(DEFAULT_IPV4_HOSTNAME), DEFAULT_PORT) + conn.connect() + mock_socket.assert_called() + mock_socket.return_value.connect.assert_called_with( + (str(DEFAULT_IPV4_HOSTNAME), DEFAULT_PORT)) + + @mock.patch('proxy.core.connection.new_socket_connection') + def testTcpServerConnectionProperty( + self, + mock_new_socket_connection: mock.Mock) -> None: + conn = TcpServerConnection( + str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT) + conn.connect() + self.assertEqual( + conn.connection, + mock_new_socket_connection.return_value) + + def testTcpServerRaisesTcpConnectionUninitializedException(self) -> None: + conn = TcpServerConnection( + str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT) + with self.assertRaises(TcpConnectionUninitializedException): + _ = conn.connection + + def testTcpClientRaisesTcpConnectionUninitializedException(self) -> None: + _conn = mock.MagicMock() + _addr = mock.MagicMock() + conn = TcpClientConnection(_conn, _addr) + conn._conn = None + with self.assertRaises(TcpConnectionUninitializedException): + _ = conn.connection diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py new file mode 100644 index 0000000000..6f20250edc --- /dev/null +++ b/tests/test_http_parser.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +from typing import Dict, Tuple + +from proxy.common.constants import CRLF +from proxy.common.utils import build_http_request, find_http_line, build_http_response, build_http_header, bytes_ +from proxy.http.methods import httpMethods +from proxy.http.codes import httpStatusCodes +from proxy.http.parser import HttpParser, httpParserTypes, httpParserStates + + +class TestHttpParser(unittest.TestCase): + + def setUp(self) -> None: + self.parser = HttpParser(httpParserTypes.REQUEST_PARSER) + + def test_build_request(self) -> None: + self.assertEqual( + build_http_request( + b'GET', b'http://localhost:12345', b'HTTP/1.1'), + CRLF.join([ + b'GET http://localhost:12345 HTTP/1.1', + CRLF + ])) + self.assertEqual( + build_http_request(b'GET', b'http://localhost:12345', b'HTTP/1.1', + headers={b'key': b'value'}), + CRLF.join([ + b'GET http://localhost:12345 HTTP/1.1', + b'key: value', + CRLF + ])) + self.assertEqual( + build_http_request(b'GET', b'http://localhost:12345', b'HTTP/1.1', + headers={b'key': b'value'}, + body=b'Hello from py'), + CRLF.join([ + b'GET http://localhost:12345 HTTP/1.1', + b'key: value', + CRLF + ]) + b'Hello from py') + + def test_build_response(self) -> None: + self.assertEqual( + build_http_response( + 200, reason=b'OK', protocol_version=b'HTTP/1.1'), + CRLF.join([ + b'HTTP/1.1 200 OK', + CRLF + ])) + self.assertEqual( + build_http_response(200, reason=b'OK', protocol_version=b'HTTP/1.1', + headers={b'key': b'value'}), + CRLF.join([ + b'HTTP/1.1 200 OK', + b'key: value', + CRLF + ])) + + def test_build_response_adds_content_length_header(self) -> None: + body = b'Hello world!!!' + self.assertEqual( + build_http_response(200, reason=b'OK', protocol_version=b'HTTP/1.1', + headers={b'key': b'value'}, + body=body), + CRLF.join([ + b'HTTP/1.1 200 OK', + b'key: value', + b'Content-Length: ' + bytes_(len(body)), + CRLF + ]) + body) + + def test_build_header(self) -> None: + self.assertEqual( + build_http_header( + b'key', b'value'), b'key: value') + + def test_header_raises(self) -> None: + with self.assertRaises(KeyError): + self.parser.header(b'not-found') + + def test_has_header(self) -> None: + self.parser.add_header(b'key', b'value') + self.assertFalse(self.parser.has_header(b'not-found')) + self.assertTrue(self.parser.has_header(b'key')) + + def test_set_host_port_raises(self) -> None: + with self.assertRaises(KeyError): + self.parser.set_line_attributes() + + def test_find_line(self) -> None: + self.assertEqual( + find_http_line( + b'CONNECT python.org:443 HTTP/1.0\r\n\r\n'), + (b'CONNECT python.org:443 HTTP/1.0', + CRLF)) + + def test_find_line_returns_None(self) -> None: + self.assertEqual( + find_http_line(b'CONNECT python.org:443 HTTP/1.0'), + (None, + b'CONNECT python.org:443 HTTP/1.0')) + + def test_connect_request_with_crlf_as_separate_chunk(self) -> None: + """See https://github.com/abhinavsingh/py/issues/70 for background.""" + raw = b'CONNECT pypi.org:443 HTTP/1.0\r\n' + self.parser.parse(raw) + self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD) + self.parser.parse(CRLF) + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_get_full_parse(self) -> None: + raw = CRLF.join([ + b'GET %s HTTP/1.1', + b'Host: %s', + CRLF + ]) + pkt = raw % (b'https://example.com/path/dir/?a=b&c=d#p=q', + b'example.com') + self.parser.parse(pkt) + self.assertEqual(self.parser.total_size, len(pkt)) + self.assertEqual(self.parser.build_url(), b'/path/dir/?a=b&c=d#p=q') + self.assertEqual(self.parser.method, b'GET') + assert self.parser.url + self.assertEqual(self.parser.url.hostname, b'example.com') + self.assertEqual(self.parser.url.port, None) + self.assertEqual(self.parser.version, b'HTTP/1.1') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + self.assertDictContainsSubset( + {b'host': (b'Host', b'example.com')}, self.parser.headers) + self.parser.del_headers([b'host']) + self.parser.add_headers([(b'Host', b'example.com')]) + self.assertEqual( + raw % + (b'/path/dir/?a=b&c=d#p=q', + b'example.com'), + self.parser.build()) + + def test_build_url_none(self) -> None: + self.assertEqual(self.parser.build_url(), b'/None') + + def test_line_rcvd_to_rcving_headers_state_change(self) -> None: + pkt = b'GET http://localhost HTTP/1.1' + self.parser.parse(pkt) + self.assertEqual(self.parser.total_size, len(pkt)) + self.assert_state_change_with_crlf( + httpParserStates.INITIALIZED, + httpParserStates.LINE_RCVD, + httpParserStates.COMPLETE) + + def test_get_partial_parse1(self) -> None: + pkt = CRLF.join([ + b'GET http://localhost:8080 HTTP/1.1' + ]) + self.parser.parse(pkt) + self.assertEqual(self.parser.total_size, len(pkt)) + self.assertEqual(self.parser.method, None) + self.assertEqual(self.parser.url, None) + self.assertEqual(self.parser.version, None) + self.assertEqual( + self.parser.state, + httpParserStates.INITIALIZED) + + self.parser.parse(CRLF) + self.assertEqual(self.parser.total_size, len(pkt) + len(CRLF)) + self.assertEqual(self.parser.method, b'GET') + assert self.parser.url + self.assertEqual(self.parser.url.hostname, b'localhost') + self.assertEqual(self.parser.url.port, 8080) + self.assertEqual(self.parser.version, b'HTTP/1.1') + self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD) + + host_hdr = b'Host: localhost:8080' + self.parser.parse(host_hdr) + self.assertEqual(self.parser.total_size, + len(pkt) + len(CRLF) + len(host_hdr)) + self.assertDictEqual(self.parser.headers, dict()) + self.assertEqual(self.parser.buffer, b'Host: localhost:8080') + self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD) + + self.parser.parse(CRLF * 2) + self.assertEqual(self.parser.total_size, len(pkt) + + (3 * len(CRLF)) + len(host_hdr)) + self.assertDictContainsSubset( + {b'host': (b'Host', b'localhost:8080')}, self.parser.headers) + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_get_partial_parse2(self) -> None: + self.parser.parse(CRLF.join([ + b'GET http://localhost:8080 HTTP/1.1', + b'Host: ' + ])) + self.assertEqual(self.parser.method, b'GET') + assert self.parser.url + self.assertEqual(self.parser.url.hostname, b'localhost') + self.assertEqual(self.parser.url.port, 8080) + self.assertEqual(self.parser.version, b'HTTP/1.1') + self.assertEqual(self.parser.buffer, b'Host: ') + self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD) + + self.parser.parse(b'localhost:8080' + CRLF) + self.assertDictContainsSubset( + {b'host': (b'Host', b'localhost:8080')}, self.parser.headers) + self.assertEqual(self.parser.buffer, b'') + self.assertEqual( + self.parser.state, + httpParserStates.RCVING_HEADERS) + + self.parser.parse(b'Content-Type: text/plain' + CRLF) + self.assertEqual(self.parser.buffer, b'') + self.assertDictContainsSubset( + {b'content-type': (b'Content-Type', b'text/plain')}, self.parser.headers) + self.assertEqual( + self.parser.state, + httpParserStates.RCVING_HEADERS) + + self.parser.parse(CRLF) + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_post_full_parse(self) -> None: + raw = CRLF.join([ + b'POST %s HTTP/1.1', + b'Host: localhost', + b'Content-Length: 7', + b'Content-Type: application/x-www-form-urlencoded' + CRLF, + b'a=b&c=d' + ]) + self.parser.parse(raw % b'http://localhost') + self.assertEqual(self.parser.method, b'POST') + assert self.parser.url + self.assertEqual(self.parser.url.hostname, b'localhost') + self.assertEqual(self.parser.url.port, None) + self.assertEqual(self.parser.version, b'HTTP/1.1') + self.assertDictContainsSubset( + {b'content-type': (b'Content-Type', b'application/x-www-form-urlencoded')}, self.parser.headers) + self.assertDictContainsSubset( + {b'content-length': (b'Content-Length', b'7')}, self.parser.headers) + self.assertEqual(self.parser.body, b'a=b&c=d') + self.assertEqual(self.parser.buffer, b'') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + self.assertEqual(len(self.parser.build()), len(raw % b'/')) + + def assert_state_change_with_crlf(self, + initial_state: int, + next_state: int, + final_state: int) -> None: + self.assertEqual(self.parser.state, initial_state) + self.parser.parse(CRLF) + self.assertEqual(self.parser.state, next_state) + self.parser.parse(CRLF) + self.assertEqual(self.parser.state, final_state) + + def test_post_partial_parse(self) -> None: + self.parser.parse(CRLF.join([ + b'POST http://localhost HTTP/1.1', + b'Host: localhost', + b'Content-Length: 7', + b'Content-Type: application/x-www-form-urlencoded' + ])) + self.assertEqual(self.parser.method, b'POST') + assert self.parser.url + self.assertEqual(self.parser.url.hostname, b'localhost') + self.assertEqual(self.parser.url.port, None) + self.assertEqual(self.parser.version, b'HTTP/1.1') + self.assert_state_change_with_crlf( + httpParserStates.RCVING_HEADERS, + httpParserStates.RCVING_HEADERS, + httpParserStates.HEADERS_COMPLETE) + + self.parser.parse(b'a=b') + self.assertEqual( + self.parser.state, + httpParserStates.RCVING_BODY) + self.assertEqual(self.parser.body, b'a=b') + self.assertEqual(self.parser.buffer, b'') + + self.parser.parse(b'&c=d') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + self.assertEqual(self.parser.body, b'a=b&c=d') + self.assertEqual(self.parser.buffer, b'') + + def test_connect_request_without_host_header_request_parse(self) -> None: + """Case where clients can send CONNECT request without a Host header field. + + Example: + 1. pip3 --proxy http://localhost:8899 install + Uses HTTP/1.0, Host header missing with CONNECT requests + 2. Android Emulator + Uses HTTP/1.1, Host header missing with CONNECT requests + + See https://github.com/abhinavsingh/py/issues/5 for details. + """ + self.parser.parse(b'CONNECT pypi.org:443 HTTP/1.0\r\n\r\n') + self.assertEqual(self.parser.method, b'CONNECT') + self.assertEqual(self.parser.version, b'HTTP/1.0') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_request_parse_without_content_length(self) -> None: + """Case when incoming request doesn't contain a content-length header. + + From http://w3-org.9356.n7.nabble.com/POST-with-empty-body-td103965.html + 'A POST with no content-length and no body is equivalent to a POST with Content-Length: 0 + and nothing following, as could perfectly happen when you upload an empty file for instance.' + + See https://github.com/abhinavsingh/py/issues/20 for details. + """ + self.parser.parse(CRLF.join([ + b'POST http://localhost HTTP/1.1', + b'Host: localhost', + b'Content-Type: application/x-www-form-urlencoded', + CRLF + ])) + self.assertEqual(self.parser.method, b'POST') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_response_parse_without_content_length(self) -> None: + """Case when server response doesn't contain a content-length header for non-chunk response types. + + HttpParser by itself has no way to know if more data should be expected. + In example below, parser reaches state httpParserStates.HEADERS_COMPLETE + and it is responsibility of callee to change state to httpParserStates.COMPLETE + when server stream closes. + + See https://github.com/abhinavsingh/py/issues/20 for details. + """ + self.parser.type = httpParserTypes.RESPONSE_PARSER + self.parser.parse(b'HTTP/1.0 200 OK' + CRLF) + self.assertEqual(self.parser.code, b'200') + self.assertEqual(self.parser.version, b'HTTP/1.0') + self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD) + self.parser.parse(CRLF.join([ + b'Server: BaseHTTP/0.3 Python/2.7.10', + b'Date: Thu, 13 Dec 2018 16:24:09 GMT', + CRLF + ])) + self.assertEqual( + self.parser.state, + httpParserStates.HEADERS_COMPLETE) + + def test_response_parse(self) -> None: + self.parser.type = httpParserTypes.RESPONSE_PARSER + self.parser.parse(b''.join([ + b'HTTP/1.1 301 Moved Permanently\r\n', + b'Location: http://www.google.com/\r\n', + b'Content-Type: text/html; charset=UTF-8\r\n', + b'Date: Wed, 22 May 2013 14:07:29 GMT\r\n', + b'Expires: Fri, 21 Jun 2013 14:07:29 GMT\r\n', + b'Cache-Control: public, max-age=2592000\r\n', + b'Server: gws\r\n', + b'Content-Length: 219\r\n', + b'X-XSS-Protection: 1; mode=block\r\n', + b'X-Frame-Options: SAMEORIGIN\r\n\r\n', + b'\n' + + b'301 Moved', + b'\n

301 Moved

\nThe document has moved\n' + + b'here.\r\n\r\n' + ])) + self.assertEqual(self.parser.code, b'301') + self.assertEqual(self.parser.reason, b'Moved Permanently') + self.assertEqual(self.parser.version, b'HTTP/1.1') + self.assertEqual( + self.parser.body, + b'\n' + + b'301 Moved\n

301 Moved

\nThe document has moved\n' + + b'here.\r\n\r\n') + self.assertDictContainsSubset( + {b'content-length': (b'Content-Length', b'219')}, self.parser.headers) + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_response_partial_parse(self) -> None: + self.parser.type = httpParserTypes.RESPONSE_PARSER + self.parser.parse(b''.join([ + b'HTTP/1.1 301 Moved Permanently\r\n', + b'Location: http://www.google.com/\r\n', + b'Content-Type: text/html; charset=UTF-8\r\n', + b'Date: Wed, 22 May 2013 14:07:29 GMT\r\n', + b'Expires: Fri, 21 Jun 2013 14:07:29 GMT\r\n', + b'Cache-Control: public, max-age=2592000\r\n', + b'Server: gws\r\n', + b'Content-Length: 219\r\n', + b'X-XSS-Protection: 1; mode=block\r\n', + b'X-Frame-Options: SAMEORIGIN\r\n' + ])) + self.assertDictContainsSubset( + {b'x-frame-options': (b'X-Frame-Options', b'SAMEORIGIN')}, self.parser.headers) + self.assertEqual( + self.parser.state, + httpParserStates.RCVING_HEADERS) + self.parser.parse(b'\r\n') + self.assertEqual( + self.parser.state, + httpParserStates.HEADERS_COMPLETE) + self.parser.parse( + b'\n' + + b'301 Moved') + self.assertEqual( + self.parser.state, + httpParserStates.RCVING_BODY) + self.parser.parse( + b'\n

301 Moved

\nThe document has moved\n' + + b'here.\r\n\r\n') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_chunked_response_parse(self) -> None: + self.parser.type = httpParserTypes.RESPONSE_PARSER + self.parser.parse(b''.join([ + b'HTTP/1.1 200 OK\r\n', + b'Content-Type: application/json\r\n', + b'Date: Wed, 22 May 2013 15:08:15 GMT\r\n', + b'Server: gunicorn/0.16.1\r\n', + b'transfer-encoding: chunked\r\n', + b'Connection: keep-alive\r\n\r\n', + b'4\r\n', + b'Wiki\r\n', + b'5\r\n', + b'pedia\r\n', + b'E\r\n', + b' in\r\n\r\nchunks.\r\n', + b'0\r\n', + b'\r\n' + ])) + self.assertEqual(self.parser.body, b'Wikipedia in\r\n\r\nchunks.') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + + def test_pipelined_response_parse(self) -> None: + response = build_http_response( + httpStatusCodes.OK, reason=b'OK', + headers={ + b'Content-Length': b'15' + }, + body=b'{"key":"value"}', + ) + self.assert_pipeline_response(response) + + def test_pipelined_chunked_response_parse(self) -> None: + response = build_http_response( + httpStatusCodes.OK, reason=b'OK', + headers={ + b'Transfer-Encoding': b'chunked', + b'Content-Type': b'application/json', + }, + body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n' + ) + self.assert_pipeline_response(response) + + def assert_pipeline_response(self, response: bytes) -> None: + self.parser = HttpParser(httpParserTypes.RESPONSE_PARSER) + self.parser.parse(response + response) + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + self.assertEqual(self.parser.body, b'{"key":"value"}') + self.assertEqual(self.parser.buffer, response) + + # parse buffer + parser = HttpParser(httpParserTypes.RESPONSE_PARSER) + parser.parse(self.parser.buffer) + self.assertEqual(parser.state, httpParserStates.COMPLETE) + self.assertEqual(parser.body, b'{"key":"value"}') + self.assertEqual(parser.buffer, b'') + + def test_chunked_request_parse(self) -> None: + self.parser.parse(build_http_request( + httpMethods.POST, b'http://example.org/', + headers={ + b'Transfer-Encoding': b'chunked', + b'Content-Type': b'application/json', + }, + body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n')) + self.assertEqual(self.parser.body, b'{"key":"value"}') + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) + self.assertEqual(self.parser.build(), build_http_request( + httpMethods.POST, b'/', + headers={ + b'Transfer-Encoding': b'chunked', + b'Content-Type': b'application/json', + }, + body=b'f\r\n{"key":"value"}\r\n0\r\n\r\n')) + + def test_is_http_1_1_keep_alive(self) -> None: + self.parser.parse(build_http_request( + httpMethods.GET, b'/' + )) + self.assertTrue(self.parser.is_http_1_1_keep_alive()) + + def test_is_http_1_1_keep_alive_with_non_close_connection_header( + self) -> None: + self.parser.parse(build_http_request( + httpMethods.GET, b'/', + headers={ + b'Connection': b'keep-alive', + } + )) + self.assertTrue(self.parser.is_http_1_1_keep_alive()) + + def test_is_not_http_1_1_keep_alive_with_close_header(self) -> None: + self.parser.parse(build_http_request( + httpMethods.GET, b'/', + headers={ + b'Connection': b'close', + } + )) + self.assertFalse(self.parser.is_http_1_1_keep_alive()) + + def test_is_not_http_1_1_keep_alive_for_http_1_0(self) -> None: + self.parser.parse(build_http_request( + httpMethods.GET, b'/', protocol_version=b'HTTP/1.0', + )) + self.assertFalse(self.parser.is_http_1_1_keep_alive()) + + def assertDictContainsSubset(self, subset: Dict[bytes, Tuple[bytes, bytes]], + dictionary: Dict[bytes, Tuple[bytes, bytes]]) -> None: + for k in subset.keys(): + self.assertTrue(k in dictionary) diff --git a/tests/test_http_proxy.py b/tests/test_http_proxy.py new file mode 100644 index 0000000000..8d84c310db --- /dev/null +++ b/tests/test_http_proxy.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import selectors +from unittest import mock + +from proxy.common.flags import Flags +from proxy.http.proxy import HttpProxyPlugin +from proxy.http.handler import HttpProtocolHandler +from proxy.http.exception import HttpProtocolException +from proxy.common.utils import build_http_request + + +class TestHttpProxyPlugin(unittest.TestCase): + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.flags = Flags() + self.plugin = mock.MagicMock() + self.flags.plugins = { + b'HttpProtocolHandlerPlugin': [HttpProxyPlugin], + b'HttpProxyBasePlugin': [self.plugin] + } + self._conn = mock_fromfd.return_value + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + def test_proxy_plugin_initialized(self) -> None: + self.plugin.assert_called() + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_proxy_plugin_on_and_before_upstream_connection( + self, + mock_server_conn: mock.Mock) -> None: + self.plugin.return_value.before_upstream_connection.side_effect = lambda r: r + self.plugin.return_value.handle_client_request.side_effect = lambda r: r + + self._conn.recv.return_value = build_http_request( + b'GET', b'http://upstream.host/not-found.html', + headers={ + b'Host': b'upstream.host' + }) + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + self.protocol_handler.run_once() + mock_server_conn.assert_called_with('upstream.host', 80) + self.plugin.return_value.before_upstream_connection.assert_called() + self.plugin.return_value.handle_client_request.assert_called() + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_proxy_plugin_before_upstream_connection_can_teardown( + self, + mock_server_conn: mock.Mock) -> None: + self.plugin.return_value.before_upstream_connection.side_effect = HttpProtocolException() + + self._conn.recv.return_value = build_http_request( + b'GET', b'http://upstream.host/not-found.html', + headers={ + b'Host': b'upstream.host' + }) + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + self.protocol_handler.run_once() + self.plugin.return_value.before_upstream_connection.assert_called() + mock_server_conn.assert_not_called() diff --git a/tests/test_http_proxy_examples.py b/tests/test_http_proxy_examples.py new file mode 100644 index 0000000000..19bf6496f9 --- /dev/null +++ b/tests/test_http_proxy_examples.py @@ -0,0 +1,444 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import selectors +import ssl +import socket +import json + +from urllib import parse as urlparse +from unittest import mock +from typing import Type, cast, Any + +from proxy.common.flags import Flags +from proxy.http.handler import HttpProtocolHandler +from proxy.http.proxy import HttpProxyBasePlugin, HttpProxyPlugin +from proxy.common.utils import build_http_request, bytes_, build_http_response +from proxy.common.constants import PROXY_AGENT_HEADER_VALUE +from proxy.http.codes import httpStatusCodes +from proxy.http.methods import httpMethods + +from plugin_examples import modify_post_data +from plugin_examples import mock_rest_api +from plugin_examples import redirect_to_custom_server +from plugin_examples import filter_by_upstream +from plugin_examples import cache_responses +from plugin_examples import man_in_the_middle + + +def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]: + plugin: Type[HttpProxyBasePlugin] = modify_post_data.ModifyPostDataPlugin + if test_name == 'test_modify_post_data_plugin': + plugin = modify_post_data.ModifyPostDataPlugin + elif test_name == 'test_proposed_rest_api_plugin': + plugin = mock_rest_api.ProposedRestApiPlugin + elif test_name == 'test_redirect_to_custom_server_plugin': + plugin = redirect_to_custom_server.RedirectToCustomServerPlugin + elif test_name == 'test_filter_by_upstream_host_plugin': + plugin = filter_by_upstream.FilterByUpstreamHostPlugin + elif test_name == 'test_cache_responses_plugin': + plugin = cache_responses.CacheResponsesPlugin + elif test_name == 'test_man_in_the_middle_plugin': + plugin = man_in_the_middle.ManInTheMiddlePlugin + return plugin + + +class TestHttpProxyPluginExamples(unittest.TestCase): + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.flags = Flags() + self.plugin = mock.MagicMock() + + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + + plugin = get_plugin_by_test_name(self._testMethodName) + + self.flags.plugins = { + b'HttpProtocolHandlerPlugin': [HttpProxyPlugin], + b'HttpProxyBasePlugin': [plugin], + } + self._conn = mock_fromfd.return_value + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_modify_post_data_plugin( + self, mock_server_conn: mock.Mock) -> None: + original = b'{"key": "value"}' + modified = b'{"key": "modified"}' + + self._conn.recv.return_value = build_http_request( + b'POST', b'http://httpbin.org/post', + headers={ + b'Host': b'httpbin.org', + b'Content-Type': b'application/x-www-form-urlencoded', + b'Content-Length': bytes_(len(original)), + }, + body=original + ) + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + self.protocol_handler.run_once() + mock_server_conn.assert_called_with('httpbin.org', 80) + mock_server_conn.return_value.queue.assert_called_with( + build_http_request( + b'POST', b'/post', + headers={ + b'Host': b'httpbin.org', + b'Content-Length': bytes_(len(modified)), + b'Content-Type': b'application/json', + b'Via': b'1.1 %s' % PROXY_AGENT_HEADER_VALUE, + }, + body=modified + ) + ) + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_proposed_rest_api_plugin( + self, mock_server_conn: mock.Mock) -> None: + path = b'/v1/users/' + self._conn.recv.return_value = build_http_request( + b'GET', b'http://%s%s' % ( + mock_rest_api.ProposedRestApiPlugin.API_SERVER, path), + headers={ + b'Host': mock_rest_api.ProposedRestApiPlugin.API_SERVER, + } + ) + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.protocol_handler.run_once() + + mock_server_conn.assert_not_called() + self.assertEqual( + self.protocol_handler.client.buffer, + build_http_response( + httpStatusCodes.OK, reason=b'OK', + headers={b'Content-Type': b'application/json'}, + body=bytes_( + json.dumps( + mock_rest_api.ProposedRestApiPlugin.REST_API_SPEC[path])) + )) + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_redirect_to_custom_server_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = build_http_request( + b'GET', b'http://example.org/get', + headers={ + b'Host': b'example.org', + } + ) + self._conn.recv.return_value = request + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.protocol_handler.run_once() + + upstream = urlparse.urlsplit( + redirect_to_custom_server.RedirectToCustomServerPlugin.UPSTREAM_SERVER) + mock_server_conn.assert_called_with('localhost', 8899) + mock_server_conn.return_value.queue.assert_called_with( + build_http_request( + b'GET', upstream.path, + headers={ + b'Host': upstream.netloc, + b'Via': b'1.1 %s' % PROXY_AGENT_HEADER_VALUE, + } + ) + ) + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_filter_by_upstream_host_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = build_http_request( + b'GET', b'http://google.com/', + headers={ + b'Host': b'google.com', + } + ) + self._conn.recv.return_value = request + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.protocol_handler.run_once() + + mock_server_conn.assert_not_called() + self.assertEqual( + self.protocol_handler.client.buffer, + build_http_response( + status_code=httpStatusCodes.I_AM_A_TEAPOT, + reason=b'I\'m a tea pot', + headers={ + b'Connection': b'close' + }, + ) + ) + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_man_in_the_middle_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = build_http_request( + b'GET', b'http://super.secure/', + headers={ + b'Host': b'super.secure', + } + ) + self._conn.recv.return_value = request + + server = mock_server_conn.return_value + server.connect.return_value = True + + def has_buffer() -> bool: + return cast(bool, server.queue.called) + + def closed() -> bool: + return not server.connect.called + + server.has_buffer.side_effect = has_buffer + type(server).closed = mock.PropertyMock(side_effect=closed) + + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=server.connection, + fd=server.connection.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], + [(selectors.SelectorKey( + fileobj=server.connection, + fd=server.connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + # Client read + self.protocol_handler.run_once() + mock_server_conn.assert_called_with('super.secure', 80) + server.connect.assert_called_once() + queued_request = \ + build_http_request( + b'GET', b'/', + headers={ + b'Host': b'super.secure', + b'Via': b'1.1 %s' % PROXY_AGENT_HEADER_VALUE + } + ) + server.queue.assert_called_once_with(queued_request) + + # Server write + self.protocol_handler.run_once() + server.flush.assert_called_once() + + # Server read + server.recv.return_value = \ + build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Original Response From Upstream') + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.client.buffer, + build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') + ) + + +class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): + + @mock.patch('ssl.wrap_socket') + @mock.patch('ssl.create_default_context') + @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('subprocess.Popen') + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_popen: mock.Mock, + mock_server_conn: mock.Mock, + mock_ssl_context: mock.Mock, + mock_ssl_wrap: mock.Mock) -> None: + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + self.mock_popen = mock_popen + self.mock_server_conn = mock_server_conn + self.mock_ssl_context = mock_ssl_context + self.mock_ssl_wrap = mock_ssl_wrap + + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.flags = Flags( + ca_cert_file='ca-cert.pem', + ca_key_file='ca-key.pem', + ca_signing_key_file='ca-signing-key.pem',) + self.plugin = mock.MagicMock() + + plugin = get_plugin_by_test_name(self._testMethodName) + + self.flags.plugins = { + b'HttpProtocolHandlerPlugin': [HttpProxyPlugin], + b'HttpProxyBasePlugin': [plugin], + } + self._conn = mock.MagicMock(spec=socket.socket) + mock_fromfd.return_value = self._conn + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + self.server = self.mock_server_conn.return_value + + self.server_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_context.return_value.wrap_socket.return_value = self.server_ssl_connection + self.client_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_wrap.return_value = self.client_ssl_connection + + def has_buffer() -> bool: + return cast(bool, self.server.queue.called) + + def closed() -> bool: + return not self.server.connect.called + + def mock_connection() -> Any: + if self.mock_ssl_context.return_value.wrap_socket.called: + return self.server_ssl_connection + return self._conn + + self.server.has_buffer.side_effect = has_buffer + type(self.server).closed = mock.PropertyMock(side_effect=closed) + type( + self.server).connection = mock.PropertyMock( + side_effect=mock_connection) + + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self.client_ssl_connection, + fd=self.client_ssl_connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self.server_ssl_connection, + fd=self.server_ssl_connection.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], + [(selectors.SelectorKey( + fileobj=self.server_ssl_connection, + fd=self.server_ssl_connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + # Connect + def send(raw: bytes) -> int: + return len(raw) + + self._conn.send.side_effect = send + self._conn.recv.return_value = build_http_request( + httpMethods.CONNECT, b'uni.corn:443' + ) + self.protocol_handler.run_once() + + self.mock_popen.assert_called() + self.mock_server_conn.assert_called_once_with('uni.corn', 443) + self.server.connect.assert_called() + self.assertEqual( + self.protocol_handler.client.connection, + self.client_ssl_connection) + self.assertEqual(self.server.connection, self.server_ssl_connection) + self._conn.send.assert_called_with( + HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT + ) + self.assertEqual(self.protocol_handler.client.buffer, b'') + + def test_modify_post_data_plugin(self) -> None: + original = b'{"key": "value"}' + modified = b'{"key": "modified"}' + self.client_ssl_connection.recv.return_value = build_http_request( + b'POST', b'/', + headers={ + b'Host': b'uni.corn', + b'Content-Type': b'application/x-www-form-urlencoded', + b'Content-Length': bytes_(len(original)), + }, + body=original + ) + self.protocol_handler.run_once() + self.server.queue.assert_called_with( + build_http_request( + b'POST', b'/', + headers={ + b'Host': b'uni.corn', + b'Content-Length': bytes_(len(modified)), + b'Content-Type': b'application/json', + }, + body=modified + ) + ) + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_man_in_the_middle_plugin( + self, mock_server_conn: mock.Mock) -> None: + request = build_http_request( + b'GET', b'/', + headers={ + b'Host': b'uni.corn', + } + ) + self.client_ssl_connection.recv.return_value = request + + # Client read + self.protocol_handler.run_once() + self.server.queue.assert_called_once_with(request) + + # Server write + self.protocol_handler.run_once() + self.server.flush.assert_called_once() + + # Server read + self.server.recv.return_value = \ + build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Original Response From Upstream') + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.client.buffer, + build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') + ) diff --git a/tests/test_http_proxy_tls_interception.py b/tests/test_http_proxy_tls_interception.py new file mode 100644 index 0000000000..42575b0c5b --- /dev/null +++ b/tests/test_http_proxy_tls_interception.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import uuid +import unittest +import socket +import ssl +import selectors + +from typing import Any +from unittest import mock + +from proxy.http.handler import HttpProtocolHandler +from proxy.http.proxy import HttpProxyPlugin +from proxy.http.methods import httpMethods +from proxy.common.utils import build_http_request, bytes_ +from proxy.common.flags import Flags + + +class TestHttpProxyTlsInterception(unittest.TestCase): + + @mock.patch('ssl.wrap_socket') + @mock.patch('ssl.create_default_context') + @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('subprocess.Popen') + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_e2e( + self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_popen: mock.Mock, + mock_server_conn: mock.Mock, + mock_ssl_context: mock.Mock, + mock_ssl_wrap: mock.Mock) -> None: + host, port = uuid.uuid4().hex, 443 + netloc = '{0}:{1}'.format(host, port) + + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + self.mock_popen = mock_popen + self.mock_server_conn = mock_server_conn + self.mock_ssl_context = mock_ssl_context + self.mock_ssl_wrap = mock_ssl_wrap + + ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_context.return_value.wrap_socket.return_value = ssl_connection + self.mock_ssl_wrap.return_value = mock.MagicMock(spec=ssl.SSLSocket) + plain_connection = mock.MagicMock(spec=socket.socket) + + def mock_connection() -> Any: + if self.mock_ssl_context.return_value.wrap_socket.called: + return ssl_connection + return plain_connection + + type(self.mock_server_conn.return_value).connection = \ + mock.PropertyMock(side_effect=mock_connection) + + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.flags = Flags( + ca_cert_file='ca-cert.pem', + ca_key_file='ca-key.pem', + ca_signing_key_file='ca-signing-key.pem', + ) + self.plugin = mock.MagicMock() + self.proxy_plugin = mock.MagicMock() + self.flags.plugins = { + b'HttpProtocolHandlerPlugin': [self.plugin, HttpProxyPlugin], + b'HttpProxyBasePlugin': [self.proxy_plugin], + } + self._conn = mock_fromfd.return_value + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + self.plugin.assert_called() + self.assertEqual(self.plugin.call_args[0][0], self.flags) + self.assertEqual(self.plugin.call_args[0][1].connection, self._conn) + self.proxy_plugin.assert_called() + self.assertEqual(self.proxy_plugin.call_args[0][0], self.flags) + self.assertEqual( + self.proxy_plugin.call_args[0][1].connection, + self._conn) + + connect_request = build_http_request( + httpMethods.CONNECT, bytes_(netloc), + headers={ + b'Host': bytes_(netloc), + }) + self._conn.recv.return_value = connect_request + + # Prepare mocked HttpProtocolHandlerPlugin + self.plugin.return_value.get_descriptors.return_value = ([], []) + self.plugin.return_value.write_to_descriptors.return_value = False + self.plugin.return_value.read_from_descriptors.return_value = False + self.plugin.return_value.on_client_data.side_effect = lambda raw: raw + self.plugin.return_value.on_request_complete.return_value = False + self.plugin.return_value.on_response_chunk.side_effect = lambda chunk: chunk + self.plugin.return_value.on_client_connection_close.return_value = None + + # Prepare mocked HttpProxyBasePlugin + self.proxy_plugin.return_value.before_upstream_connection.side_effect = lambda r: r + self.proxy_plugin.return_value.handle_client_request.side_effect = lambda r: r + + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + self.protocol_handler.run_once() + + # Assert our mocked plugins invocations + self.plugin.return_value.get_descriptors.assert_called() + self.plugin.return_value.write_to_descriptors.assert_called_with([]) + self.plugin.return_value.on_client_data.assert_called_with( + connect_request) + self.plugin.return_value.on_request_complete.assert_called() + self.plugin.return_value.read_from_descriptors.assert_called_with([ + self._conn]) + self.proxy_plugin.return_value.before_upstream_connection.assert_called() + self.proxy_plugin.return_value.handle_client_request.assert_called() + + self.mock_server_conn.assert_called_with(host, port) + self.mock_server_conn.return_value.connection.setblocking.assert_called_with( + False) + + self.mock_ssl_context.assert_called_with(ssl.Purpose.SERVER_AUTH) + # self.assertEqual(self.mock_ssl_context.return_value.options, + # ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | + # ssl.OP_NO_TLSv1_1) + self.assertEqual(plain_connection.setblocking.call_count, 2) + self.mock_ssl_context.return_value.wrap_socket.assert_called_with( + plain_connection, server_hostname=host) + # TODO: Assert Popen arguments, piping, success condition + self.assertEqual(self.mock_popen.call_count, 2) + self.assertEqual(ssl_connection.setblocking.call_count, 1) + self.assertEqual( + self.mock_server_conn.return_value._conn, + ssl_connection) + self._conn.send.assert_called_with( + HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) + assert self.flags.ca_cert_dir is not None + self.mock_ssl_wrap.assert_called_with( + self._conn, + server_side=True, + keyfile=self.flags.ca_signing_key_file, + certfile=HttpProxyPlugin.generated_cert_file_path( + self.flags.ca_cert_dir, host) + ) + self.assertEqual(self._conn.setblocking.call_count, 2) + self.assertEqual( + self.protocol_handler.client.connection, + self.mock_ssl_wrap.return_value) + + # Assert connection references for all other plugins is updated + self.assertEqual( + self.plugin.return_value.client._conn, + self.mock_ssl_wrap.return_value) + self.assertEqual( + self.proxy_plugin.return_value.client._conn, + self.mock_ssl_wrap.return_value) diff --git a/tests/test_http_request_rejected.py b/tests/test_http_request_rejected.py new file mode 100644 index 0000000000..828bb93d55 --- /dev/null +++ b/tests/test_http_request_rejected.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest + +from proxy.http.parser import HttpParser, httpParserTypes +from proxy.http.exception import HttpRequestRejected +from proxy.common.constants import CRLF +from proxy.common.utils import build_http_response +from proxy.http.codes import httpStatusCodes + + +class TestHttpRequestRejected(unittest.TestCase): + + def setUp(self) -> None: + self.request = HttpParser(httpParserTypes.REQUEST_PARSER) + + def test_empty_response(self) -> None: + e = HttpRequestRejected() + self.assertEqual(e.response(self.request), None) + + def test_status_code_response(self) -> None: + e = HttpRequestRejected(status_code=200, reason=b'OK') + self.assertEqual(e.response(self.request), CRLF.join([ + b'HTTP/1.1 200 OK', + CRLF + ])) + + def test_body_response(self) -> None: + e = HttpRequestRejected( + status_code=httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', + body=b'Nothing here') + self.assertEqual( + e.response(self.request), + build_http_response(httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', body=b'Nothing here')) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000000..6ebb13a11c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import logging +import tempfile +import os + +from unittest import mock + +from proxy.main import main +from proxy.common.utils import bytes_ +from proxy.http.handler import HttpProtocolHandler + +from proxy.common.constants import DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_BASIC_AUTH +from proxy.common.constants import DEFAULT_TIMEOUT, DEFAULT_DEVTOOLS_WS_PATH, DEFAULT_DISABLE_HTTP_PROXY +from proxy.common.constants import DEFAULT_ENABLE_STATIC_SERVER, DEFAULT_ENABLE_EVENTS, DEFAULT_ENABLE_DEVTOOLS +from proxy.common.constants import DEFAULT_ENABLE_WEB_SERVER, DEFAULT_THREADLESS, DEFAULT_CERT_FILE, DEFAULT_KEY_FILE +from proxy.common.constants import DEFAULT_CA_CERT_FILE, DEFAULT_CA_KEY_FILE, DEFAULT_CA_SIGNING_KEY_FILE +from proxy.common.constants import DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT +from proxy.common.constants import DEFAULT_NUM_WORKERS, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME +from proxy.common.constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE +from proxy.common.constants import COMMA +from proxy.common.version import __version__ + + +def get_temp_file(name: str) -> str: + return os.path.join(tempfile.gettempdir(), name) + + +class TestMain(unittest.TestCase): + + @staticmethod + def mock_default_args(mock_args: mock.Mock) -> None: + mock_args.version = False + mock_args.cert_file = DEFAULT_CERT_FILE + mock_args.key_file = DEFAULT_KEY_FILE + mock_args.ca_key_file = DEFAULT_CA_KEY_FILE + mock_args.ca_cert_file = DEFAULT_CA_CERT_FILE + mock_args.ca_signing_key_file = DEFAULT_CA_SIGNING_KEY_FILE + mock_args.pid_file = DEFAULT_PID_FILE + mock_args.log_file = DEFAULT_LOG_FILE + mock_args.log_level = DEFAULT_LOG_LEVEL + mock_args.log_format = DEFAULT_LOG_FORMAT + mock_args.basic_auth = DEFAULT_BASIC_AUTH + mock_args.hostname = DEFAULT_IPV6_HOSTNAME + mock_args.port = DEFAULT_PORT + mock_args.num_workers = DEFAULT_NUM_WORKERS + mock_args.disable_http_proxy = DEFAULT_DISABLE_HTTP_PROXY + mock_args.enable_web_server = DEFAULT_ENABLE_WEB_SERVER + mock_args.pac_file = DEFAULT_PAC_FILE + mock_args.plugins = DEFAULT_PLUGINS + mock_args.server_recvbuf_size = DEFAULT_SERVER_RECVBUF_SIZE + mock_args.client_recvbuf_size = DEFAULT_CLIENT_RECVBUF_SIZE + mock_args.open_file_limit = DEFAULT_OPEN_FILE_LIMIT + mock_args.enable_static_server = DEFAULT_ENABLE_STATIC_SERVER + mock_args.enable_devtools = DEFAULT_ENABLE_DEVTOOLS + mock_args.devtools_event_queue = None + mock_args.devtools_ws_path = DEFAULT_DEVTOOLS_WS_PATH + mock_args.timeout = DEFAULT_TIMEOUT + mock_args.threadless = DEFAULT_THREADLESS + mock_args.enable_events = DEFAULT_ENABLE_EVENTS + + @mock.patch('time.sleep') + @mock.patch('proxy.main.load_plugins') + @mock.patch('proxy.main.init_parser') + @mock.patch('proxy.main.set_open_file_limit') + @mock.patch('proxy.main.Flags') + @mock.patch('proxy.main.AcceptorPool') + @mock.patch('logging.basicConfig') + def test_init_with_no_arguments( + self, + mock_logging_config: mock.Mock, + mock_acceptor_pool: mock.Mock, + mock_protocol_config: mock.Mock, + mock_set_open_file_limit: mock.Mock, + mock_init_parser: mock.Mock, + mock_load_plugins: mock.Mock, + mock_sleep: mock.Mock) -> None: + mock_sleep.side_effect = KeyboardInterrupt() + + mock_args = mock_init_parser.return_value.parse_args.return_value + self.mock_default_args(mock_args) + main([]) + + mock_init_parser.assert_called() + mock_init_parser.return_value.parse_args.called_with([]) + + mock_load_plugins.assert_called_with( + b'proxy.http.proxy.HttpProxyPlugin,') + mock_logging_config.assert_called_with( + level=logging.INFO, + format=DEFAULT_LOG_FORMAT + ) + mock_set_open_file_limit.assert_called_with(mock_args.open_file_limit) + mock_protocol_config.assert_called_with( + auth_code=mock_args.basic_auth, + backlog=mock_args.backlog, + ca_cert_dir=mock_args.ca_cert_dir, + ca_cert_file=mock_args.ca_cert_file, + ca_key_file=mock_args.ca_key_file, + ca_signing_key_file=mock_args.ca_signing_key_file, + certfile=mock_args.cert_file, + client_recvbuf_size=mock_args.client_recvbuf_size, + hostname=mock_args.hostname, + keyfile=mock_args.key_file, + num_workers=0, + pac_file=mock_args.pac_file, + pac_file_url_path=mock_args.pac_file_url_path, + port=mock_args.port, + server_recvbuf_size=mock_args.server_recvbuf_size, + disable_headers=[ + header.lower() for header in bytes_( + mock_args.disable_headers).split(COMMA) if header.strip() != b''], + static_server_dir=mock_args.static_server_dir, + enable_static_server=mock_args.enable_static_server, + devtools_event_queue=None, + devtools_ws_path=DEFAULT_DEVTOOLS_WS_PATH, + timeout=DEFAULT_TIMEOUT, + threadless=DEFAULT_THREADLESS, + enable_events=DEFAULT_ENABLE_EVENTS, + ) + mock_acceptor_pool.assert_called_with( + flags=mock_protocol_config.return_value, + work_klass=HttpProtocolHandler, + ) + mock_acceptor_pool.return_value.setup.assert_called() + mock_acceptor_pool.return_value.shutdown.assert_called() + mock_sleep.assert_called() + + @mock.patch('time.sleep') + @mock.patch('os.remove') + @mock.patch('os.path.exists') + @mock.patch('builtins.open') + @mock.patch('proxy.main.init_parser') + @mock.patch('proxy.main.AcceptorPool') + def test_pid_file_is_written_and_removed( + self, + mock_acceptor_pool: mock.Mock, + mock_init_parser: mock.Mock, + mock_open: mock.Mock, + mock_exists: mock.Mock, + mock_remove: mock.Mock, + mock_sleep: mock.Mock) -> None: + pid_file = get_temp_file('pid') + mock_sleep.side_effect = KeyboardInterrupt() + mock_args = mock_init_parser.return_value.parse_args.return_value + self.mock_default_args(mock_args) + mock_args.pid_file = pid_file + main(['--pid-file', pid_file]) + mock_init_parser.assert_called() + mock_acceptor_pool.assert_called() + mock_acceptor_pool.return_value.setup.assert_called() + mock_open.assert_called_with(pid_file, 'wb') + mock_open.return_value.__enter__.return_value.write.assert_called_with( + bytes_(os.getpid())) + mock_exists.assert_called_with(pid_file) + mock_remove.assert_called_with(pid_file) + + @mock.patch('time.sleep') + @mock.patch('proxy.main.Flags') + @mock.patch('proxy.main.AcceptorPool') + def test_basic_auth( + self, + mock_acceptor_pool: mock.Mock, + mock_protocol_config: mock.Mock, + mock_sleep: mock.Mock) -> None: + mock_sleep.side_effect = KeyboardInterrupt() + main(['--basic-auth', 'user:pass']) + flags = mock_protocol_config.return_value + mock_acceptor_pool.assert_called_with( + flags=flags, + work_klass=HttpProtocolHandler) + self.assertEqual( + mock_protocol_config.call_args[1]['auth_code'], + b'Basic dXNlcjpwYXNz') + + @mock.patch('builtins.print') + def test_main_version( + self, + mock_print: mock.Mock) -> None: + with self.assertRaises(SystemExit): + main(['--version']) + mock_print.assert_called_with(__version__) + + @mock.patch('time.sleep') + @mock.patch('builtins.print') + @mock.patch('proxy.main.AcceptorPool') + @mock.patch('proxy.main.is_py3') + def test_main_py3_runs( + self, + mock_is_py3: mock.Mock, + mock_acceptor_pool: mock.Mock, + mock_print: mock.Mock, + mock_sleep: mock.Mock) -> None: + mock_sleep.side_effect = KeyboardInterrupt() + mock_is_py3.return_value = True + main([]) + mock_is_py3.assert_called() + mock_print.assert_not_called() + mock_acceptor_pool.assert_called() + mock_acceptor_pool.return_value.setup.assert_called() + + @mock.patch('builtins.print') + @mock.patch('proxy.main.is_py3') + def test_main_py2_exit( + self, + mock_is_py3: mock.Mock, + mock_print: mock.Mock) -> None: + mock_is_py3.return_value = False + with self.assertRaises(SystemExit) as e: + main([]) + mock_print.assert_called_with( + 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' + 'If for some reasons you cannot upgrade, consider using "master" branch or simply ' + '"pip install proxy.py==0.3".' + '\n\n' + 'DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. ' + 'Please upgrade your Python as Python 2.7 won\'t be maintained after that date. ' + 'A future version of pip will drop support for Python 2.7.' + ) + self.assertEqual(e.exception.code, 1) + mock_is_py3.assert_called() diff --git a/tests/test_protocol_handler.py b/tests/test_protocol_handler.py new file mode 100644 index 0000000000..37a62b233e --- /dev/null +++ b/tests/test_protocol_handler.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import selectors +import base64 + +from typing import cast +from unittest import mock + +from proxy.common.flags import Flags +from proxy.common.utils import bytes_ +from proxy.common.constants import CRLF +from proxy.http.parser import HttpParser +from proxy.http.proxy import HttpProxyPlugin +from proxy.http.parser import httpParserStates, httpParserTypes +from proxy.http.exception import ProxyAuthenticationFailed, ProxyConnectionFailed +from proxy.http.handler import HttpProtocolHandler +from proxy.main import load_plugins +from proxy.common.version import __version__ + + +class TestHttpProtocolHandler(unittest.TestCase): + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self._conn = mock_fromfd.return_value + + self.http_server_port = 65535 + self.flags = Flags() + self.flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + + self.mock_selector = mock_selector + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_http_get(self, mock_server_connection: mock.Mock) -> None: + server = mock_server_connection.return_value + server.connect.return_value = True + server.buffer_size.return_value = 0 + self.mock_selector_for_client_read_read_server_write( + self.mock_selector, server) + + # Send request line + assert self.http_server_port is not None + self._conn.recv.return_value = (b'GET http://localhost:%d HTTP/1.1' % + self.http_server_port) + CRLF + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.LINE_RCVD) + self.assertNotEqual( + self.protocol_handler.request.state, + httpParserStates.COMPLETE) + + # Send headers and blank line, thus completing HTTP request + assert self.http_server_port is not None + self._conn.recv.return_value = CRLF.join([ + b'User-Agent: proxy.py/%s' % bytes_(__version__), + b'Host: localhost:%d' % self.http_server_port, + b'Accept: */*', + b'Proxy-Connection: Keep-Alive', + CRLF + ]) + self.assert_data_queued(mock_server_connection, server) + self.protocol_handler.run_once() + server.flush.assert_called_once() + + def assert_tunnel_response( + self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: + self.protocol_handler.run_once() + self.assertTrue( + cast(HttpProxyPlugin, self.protocol_handler.plugins['HttpProxyPlugin']).server is not None) + self.assertEqual( + self.protocol_handler.client.buffer, + HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) + mock_server_connection.assert_called_once() + server.connect.assert_called_once() + server.queue.assert_not_called() + server.closed = False + + parser = HttpParser(httpParserTypes.RESPONSE_PARSER) + parser.parse(self.protocol_handler.client.buffer) + self.assertEqual(parser.state, httpParserStates.COMPLETE) + assert parser.code is not None + self.assertEqual(int(parser.code), 200) + + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_http_tunnel(self, mock_server_connection: mock.Mock) -> None: + server = mock_server_connection.return_value + server.connect.return_value = True + + def has_buffer() -> bool: + return cast(bool, server.queue.called) + + server.has_buffer.side_effect = has_buffer + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ], + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=0, + data=None), selectors.EVENT_WRITE), ], + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ], + [(selectors.SelectorKey( + fileobj=server.connection, + fd=server.connection.fileno, + events=0, + data=None), selectors.EVENT_WRITE), ], + ] + + assert self.http_server_port is not None + self._conn.recv.return_value = CRLF.join([ + b'CONNECT localhost:%d HTTP/1.1' % self.http_server_port, + b'Host: localhost:%d' % self.http_server_port, + b'User-Agent: proxy.py/%s' % bytes_(__version__), + b'Proxy-Connection: Keep-Alive', + CRLF + ]) + self.assert_tunnel_response(mock_server_connection, server) + + # Dispatch tunnel established response to client + self.protocol_handler.run_once() + self.assert_data_queued_to_server(server) + + self.protocol_handler.run_once() + self.assertEqual(server.queue.call_count, 1) + server.flush.assert_called_once() + + def test_proxy_connection_failed(self) -> None: + self.mock_selector_for_client_read(self.mock_selector) + self._conn.recv.return_value = CRLF.join([ + b'GET http://unknown.domain HTTP/1.1', + b'Host: unknown.domain', + CRLF + ]) + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.client.buffer, + ProxyConnectionFailed.RESPONSE_PKT) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_proxy_authentication_failed( + self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self._conn = mock_fromfd.return_value + self.mock_selector_for_client_read(mock_selector) + flags = Flags( + auth_code=b'Basic %s' % + base64.b64encode(b'user:pass')) + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + self._conn.recv.return_value = CRLF.join([ + b'GET http://abhinavsingh.com HTTP/1.1', + b'Host: abhinavsingh.com', + CRLF + ]) + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.client.buffer, + ProxyAuthenticationFailed.RESPONSE_PKT) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_authenticated_proxy_http_get( + self, mock_server_connection: mock.Mock, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self._conn = mock_fromfd.return_value + self.mock_selector_for_client_read(mock_selector) + + server = mock_server_connection.return_value + server.connect.return_value = True + server.buffer_size.return_value = 0 + + flags = Flags( + auth_code=b'Basic %s' % + base64.b64encode(b'user:pass')) + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + + self.protocol_handler = HttpProtocolHandler( + self.fileno, addr=self._addr, flags=flags) + self.protocol_handler.initialize() + assert self.http_server_port is not None + + self._conn.recv.return_value = b'GET http://localhost:%d HTTP/1.1' % self.http_server_port + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.INITIALIZED) + + self._conn.recv.return_value = CRLF + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.LINE_RCVD) + + assert self.http_server_port is not None + self._conn.recv.return_value = CRLF.join([ + b'User-Agent: proxy.py/%s' % bytes_(__version__), + b'Host: localhost:%d' % self.http_server_port, + b'Accept: */*', + b'Proxy-Connection: Keep-Alive', + b'Proxy-Authorization: Basic dXNlcjpwYXNz', + CRLF + ]) + self.assert_data_queued(mock_server_connection, server) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + @mock.patch('proxy.http.proxy.TcpServerConnection') + def test_authenticated_proxy_http_tunnel( + self, mock_server_connection: mock.Mock, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + server = mock_server_connection.return_value + server.connect.return_value = True + server.buffer_size.return_value = 0 + self._conn = mock_fromfd.return_value + self.mock_selector_for_client_read_read_server_write( + mock_selector, server) + + flags = Flags( + auth_code=b'Basic %s' % + base64.b64encode(b'user:pass')) + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + + assert self.http_server_port is not None + self._conn.recv.return_value = CRLF.join([ + b'CONNECT localhost:%d HTTP/1.1' % self.http_server_port, + b'Host: localhost:%d' % self.http_server_port, + b'User-Agent: proxy.py/%s' % bytes_(__version__), + b'Proxy-Connection: Keep-Alive', + b'Proxy-Authorization: Basic dXNlcjpwYXNz', + CRLF + ]) + self.assert_tunnel_response(mock_server_connection, server) + self.protocol_handler.client.flush() + self.assert_data_queued_to_server(server) + + self.protocol_handler.run_once() + server.flush.assert_called_once() + + def mock_selector_for_client_read_read_server_write( + self, mock_selector: mock.Mock, server: mock.Mock) -> None: + mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ], + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=0, + data=None), selectors.EVENT_READ), ], + [(selectors.SelectorKey( + fileobj=server.connection, + fd=server.connection.fileno, + events=0, + data=None), selectors.EVENT_WRITE), ], + ] + + def assert_data_queued( + self, mock_server_connection: mock.Mock, server: mock.Mock) -> None: + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.COMPLETE) + mock_server_connection.assert_called_once() + server.connect.assert_called_once() + server.closed = False + assert self.http_server_port is not None + pkt = CRLF.join([ + b'GET / HTTP/1.1', + b'User-Agent: proxy.py/%s' % bytes_(__version__), + b'Host: localhost:%d' % self.http_server_port, + b'Accept: */*', + b'Via: 1.1 proxy.py v%s' % bytes_(__version__), + CRLF + ]) + server.queue.assert_called_once_with(pkt) + server.buffer_size.return_value = len(pkt) + + def assert_data_queued_to_server(self, server: mock.Mock) -> None: + assert self.http_server_port is not None + self.assertEqual( + self._conn.send.call_args[0][0], + HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) + + self._conn.recv.return_value = CRLF.join([ + b'GET / HTTP/1.1', + b'Host: localhost:%d' % self.http_server_port, + b'User-Agent: proxy.py/%s' % bytes_(__version__), + CRLF + ]) + self.protocol_handler.run_once() + + pkt = CRLF.join([ + b'GET / HTTP/1.1', + b'Host: localhost:%d' % self.http_server_port, + b'User-Agent: proxy.py/%s' % bytes_(__version__), + CRLF + ]) + server.queue.assert_called_once_with(pkt) + server.buffer_size.return_value = len(pkt) + server.flush.assert_not_called() + + def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: + mock_selector.return_value.select.return_value = [( + selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ] diff --git a/tests/test_set_open_file_limit.py b/tests/test_set_open_file_limit.py new file mode 100644 index 0000000000..90788cce3e --- /dev/null +++ b/tests/test_set_open_file_limit.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import unittest +from unittest import mock + +from proxy.main import set_open_file_limit + +if os.name != 'nt': + import resource + + +@unittest.skipIf( + os.name == 'nt', + 'Open file limit tests disabled for Windows') +class TestSetOpenFileLimit(unittest.TestCase): + + @mock.patch('resource.getrlimit', return_value=(128, 1024)) + @mock.patch('resource.setrlimit', return_value=None) + def test_set_open_file_limit( + self, + mock_set_rlimit: mock.Mock, + mock_get_rlimit: mock.Mock) -> None: + set_open_file_limit(256) + mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) + mock_set_rlimit.assert_called_with(resource.RLIMIT_NOFILE, (256, 1024)) + + @mock.patch('resource.getrlimit', return_value=(256, 1024)) + @mock.patch('resource.setrlimit', return_value=None) + def test_set_open_file_limit_not_called( + self, + mock_set_rlimit: mock.Mock, + mock_get_rlimit: mock.Mock) -> None: + set_open_file_limit(256) + mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) + mock_set_rlimit.assert_not_called() + + @mock.patch('resource.getrlimit', return_value=(256, 1024)) + @mock.patch('resource.setrlimit', return_value=None) + def test_set_open_file_limit_not_called_coz_upper_bound_check( + self, + mock_set_rlimit: mock.Mock, + mock_get_rlimit: mock.Mock) -> None: + set_open_file_limit(1024) + mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) + mock_set_rlimit.assert_not_called() diff --git a/tests/test_text_bytes.py b/tests/test_text_bytes.py new file mode 100644 index 0000000000..43a23559d7 --- /dev/null +++ b/tests/test_text_bytes.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest + +from proxy.common.utils import text_, bytes_ + + +class TestTextBytes(unittest.TestCase): + + def test_text(self) -> None: + self.assertEqual(text_(b'hello'), 'hello') + + def test_text_int(self) -> None: + self.assertEqual(text_(1), '1') + + def test_text_nochange(self) -> None: + self.assertEqual(text_('hello'), 'hello') + + def test_bytes(self) -> None: + self.assertEqual(bytes_('hello'), b'hello') + + def test_bytes_int(self) -> None: + self.assertEqual(bytes_(1), b'1') + + def test_bytes_nochange(self) -> None: + self.assertEqual(bytes_(b'hello'), b'hello') diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000000..2de0574f3a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +import unittest +from unittest import mock + +from proxy.common.constants import DEFAULT_IPV6_HOSTNAME, DEFAULT_IPV4_HOSTNAME, DEFAULT_PORT +from proxy.common.utils import new_socket_connection, socket_connection + + +class TestSocketConnectionUtils(unittest.TestCase): + + def setUp(self) -> None: + self.addr_ipv4 = (str(DEFAULT_IPV4_HOSTNAME), DEFAULT_PORT) + self.addr_ipv6 = (str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT) + self.addr_dual = ('httpbin.org', 80) + + @mock.patch('socket.socket') + def test_new_socket_connection_ipv4(self, mock_socket: mock.Mock) -> None: + conn = new_socket_connection(self.addr_ipv4) + mock_socket.assert_called_with(socket.AF_INET, socket.SOCK_STREAM, 0) + self.assertEqual(conn, mock_socket.return_value) + mock_socket.return_value.connect.assert_called_with(self.addr_ipv4) + + @mock.patch('socket.socket') + def test_new_socket_connection_ipv6(self, mock_socket: mock.Mock) -> None: + conn = new_socket_connection(self.addr_ipv6) + mock_socket.assert_called_with(socket.AF_INET6, socket.SOCK_STREAM, 0) + self.assertEqual(conn, mock_socket.return_value) + mock_socket.return_value.connect.assert_called_with( + (self.addr_ipv6[0], self.addr_ipv6[1], 0, 0)) + + @mock.patch('socket.create_connection') + def test_new_socket_connection_dual(self, mock_socket: mock.Mock) -> None: + conn = new_socket_connection(self.addr_dual) + mock_socket.assert_called_with(self.addr_dual) + self.assertEqual(conn, mock_socket.return_value) + + @mock.patch('proxy.common.utils.new_socket_connection') + def test_decorator(self, mock_new_socket_connection: mock.Mock) -> None: + @socket_connection(self.addr_ipv4) + def dummy(conn: socket.socket) -> None: + self.assertEqual(conn, mock_new_socket_connection.return_value) + dummy() # type: ignore + + @mock.patch('proxy.common.utils.new_socket_connection') + def test_context_manager( + self, mock_new_socket_connection: mock.Mock) -> None: + with socket_connection(self.addr_ipv4) as conn: + self.assertEqual(conn, mock_new_socket_connection.return_value) diff --git a/tests/test_web_server.py b/tests/test_web_server.py new file mode 100644 index 0000000000..ad459dcda9 --- /dev/null +++ b/tests/test_web_server.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import tempfile +import unittest +import selectors +from unittest import mock + +from proxy.main import load_plugins +from proxy.common.flags import Flags +from proxy.http.handler import HttpProtocolHandler +from proxy.http.parser import httpParserStates +from proxy.common.utils import build_http_response, build_http_request, bytes_, text_ +from proxy.common.constants import CRLF, PROXY_PY_DIR +from proxy.http.server import HttpWebServerPlugin + + +class TestWebServerPlugin(unittest.TestCase): + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self._conn = mock_fromfd.return_value + self.mock_selector = mock_selector + self.flags = Flags() + self.flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_pac_file_served_from_disk( + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + pac_file = os.path.join( + os.path.dirname(PROXY_PY_DIR), + 'helper', + 'proxy.pac') + self._conn = mock_fromfd.return_value + self.mock_selector_for_client_read(mock_selector) + self.init_and_make_pac_file_request(pac_file) + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.COMPLETE) + with open(pac_file, 'rb') as f: + self._conn.send.called_once_with(build_http_response( + 200, reason=b'OK', headers={ + b'Content-Type': b'application/x-ns-proxy-autoconfig', + b'Connection': b'close' + }, body=f.read() + )) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_pac_file_served_from_buffer( + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self._conn = mock_fromfd.return_value + self.mock_selector_for_client_read(mock_selector) + pac_file_content = b'function FindProxyForURL(url, host) { return "PROXY localhost:8899; DIRECT"; }' + self.init_and_make_pac_file_request(text_(pac_file_content)) + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.COMPLETE) + self._conn.send.called_once_with(build_http_response( + 200, reason=b'OK', headers={ + b'Content-Type': b'application/x-ns-proxy-autoconfig', + b'Connection': b'close' + }, body=pac_file_content + )) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_default_web_server_returns_404( + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + self._conn = mock_fromfd.return_value + mock_selector.return_value.select.return_value = [( + selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ] + flags = Flags() + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + self._conn.recv.return_value = CRLF.join([ + b'GET /hello HTTP/1.1', + CRLF, + ]) + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.request.state, + httpParserStates.COMPLETE) + self.assertEqual( + self.protocol_handler.client.buffer, + HttpWebServerPlugin.DEFAULT_404_RESPONSE) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_static_web_server_serves( + self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: + # Setup a static directory + static_server_dir = os.path.join(tempfile.gettempdir(), 'static') + index_file_path = os.path.join(static_server_dir, 'index.html') + html_file_content = b''' + + + + + ''' + os.makedirs(static_server_dir, exist_ok=True) + with open(index_file_path, 'wb') as f: + f.write(html_file_content) + + self._conn = mock_fromfd.return_value + self._conn.recv.return_value = build_http_request( + b'GET', b'/index.html') + + mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], ] + + flags = Flags( + enable_static_server=True, + static_server_dir=static_server_dir) + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + + self.protocol_handler.run_once() + self.protocol_handler.run_once() + + self.assertEqual(mock_selector.return_value.select.call_count, 2) + self.assertEqual(self._conn.send.call_count, 1) + self.assertEqual(self._conn.send.call_args[0][0], build_http_response( + 200, reason=b'OK', headers={ + b'Content-Type': b'text/html', + b'Connection': b'close', + b'Content-Length': bytes_(len(html_file_content)), + }, + body=html_file_content + )) + + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def test_static_web_server_serves_404( + self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock) -> None: + self._conn = mock_fromfd.return_value + self._conn.recv.return_value = build_http_request( + b'GET', b'/not-found.html') + + mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], ] + + flags = Flags(enable_static_server=True) + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') + + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + + self.protocol_handler.run_once() + self.protocol_handler.run_once() + + self.assertEqual(mock_selector.return_value.select.call_count, 2) + self.assertEqual(self._conn.send.call_count, 1) + self.assertEqual(self._conn.send.call_args[0][0], + HttpWebServerPlugin.DEFAULT_404_RESPONSE) + + @mock.patch('socket.fromfd') + def test_on_client_connection_called_on_teardown( + self, mock_fromfd: mock.Mock) -> None: + flags = Flags() + plugin = mock.MagicMock() + flags.plugins = {b'HttpProtocolHandlerPlugin': [plugin]} + self._conn = mock_fromfd.return_value + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + plugin.assert_called() + with mock.patch.object(self.protocol_handler, 'run_once') as mock_run_once: + mock_run_once.return_value = True + self.protocol_handler.run() + self.assertTrue(self._conn.closed) + plugin.return_value.on_client_connection_close.assert_called() + + def init_and_make_pac_file_request(self, pac_file: str) -> None: + flags = Flags(pac_file=pac_file) + flags.plugins = load_plugins( + b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin,' + b'proxy.http.server.HttpWebServerPacFilePlugin') + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=flags) + self.protocol_handler.initialize() + self._conn.recv.return_value = CRLF.join([ + b'GET / HTTP/1.1', + CRLF, + ]) + + def mock_selector_for_client_read(self, mock_selector: mock.Mock) -> None: + mock_selector.return_value.select.return_value = [( + selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ), ] diff --git a/tests/test_websocket_client.py b/tests/test_websocket_client.py new file mode 100644 index 0000000000..3c79307166 --- /dev/null +++ b/tests/test_websocket_client.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +from unittest import mock + +from proxy.common.utils import build_websocket_handshake_response, build_websocket_handshake_request +from proxy.http.websocket import WebsocketClient, WebsocketFrame +from proxy.common.constants import DEFAULT_IPV4_HOSTNAME, DEFAULT_PORT + + +class TestWebsocketClient(unittest.TestCase): + + @mock.patch('base64.b64encode') + @mock.patch('proxy.http.websocket.new_socket_connection') + def test_handshake(self, mock_connect: mock.Mock, + mock_b64encode: mock.Mock) -> None: + key = b'MySecretKey' + mock_b64encode.return_value = key + mock_connect.return_value.recv.return_value = \ + build_websocket_handshake_response( + WebsocketFrame.key_to_accept(key)) + _ = WebsocketClient(DEFAULT_IPV4_HOSTNAME, DEFAULT_PORT) + mock_connect.return_value.send.assert_called_with( + build_websocket_handshake_request(key) + ) diff --git a/tests/test_websocket_frame.py b/tests/test_websocket_frame.py new file mode 100644 index 0000000000..0dfc77d13c --- /dev/null +++ b/tests/test_websocket_frame.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest + +from proxy.http.websocket import WebsocketFrame, websocketOpcodes + + +class TestWebsocketFrame(unittest.TestCase): + + def test_build_with_mask(self) -> None: + raw = b'\x81\x85\xc6\ti\x8d\xael\x05\xe1\xa9' + frame = WebsocketFrame() + frame.fin = True + frame.opcode = websocketOpcodes.TEXT_FRAME + frame.masked = True + frame.mask = b'\xc6\ti\x8d' + frame.data = b'hello' + self.assertEqual(frame.build(), raw) + + def test_parse_with_mask(self) -> None: + raw = b'\x81\x85\xc6\ti\x8d\xael\x05\xe1\xa9' + frame = WebsocketFrame() + frame.parse(raw) + self.assertEqual(frame.fin, True) + self.assertEqual(frame.rsv1, False) + self.assertEqual(frame.rsv2, False) + self.assertEqual(frame.rsv3, False) + self.assertEqual(frame.opcode, 0x1) + self.assertEqual(frame.masked, True) + assert frame.mask is not None + self.assertEqual(frame.mask, b'\xc6\ti\x8d') + self.assertEqual(frame.payload_length, 5) + self.assertEqual(frame.data, b'hello') From 1ce16b95deae935a0aba2d972f6cf9f82de2cde9 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 28 Oct 2019 15:29:33 -0700 Subject: [PATCH 020/107] Update mypy==0.740 (#151) --- proxy/common/utils.py | 3 ++- proxy/http/parser.py | 2 +- requirements-testing.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/proxy/common/utils.py b/proxy/common/utils.py index 61a3b9cc39..062a567b9e 100644 --- a/proxy/common/utils.py +++ b/proxy/common/utils.py @@ -14,6 +14,7 @@ from types import TracebackType from typing import Optional, Dict, Any, List, Tuple, Type, Callable +from typing_extensions import Literal from .constants import HTTP_1_1, COLON, WHITESPACE, CRLF @@ -185,7 +186,7 @@ def __exit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType]) -> bool: + exc_tb: Optional[TracebackType]) -> Literal[False]: if self.conn: self.conn.close() return False diff --git a/proxy/http/parser.py b/proxy/http/parser.py index efcac32c68..a56f1d37a0 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -117,7 +117,7 @@ def set_line_attributes(self) -> None: self.host, self.port = self.url.hostname, self.url.port \ if self.url.port else 80 else: - raise KeyError('Invalid request\n%s' % self.bytes) + raise KeyError(b'Invalid request\n%b' % self.bytes) self.path = self.build_url() def is_chunked_encoded(self) -> bool: diff --git a/requirements-testing.txt b/requirements-testing.txt index f1f0886d78..a9c8c76242 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -4,6 +4,6 @@ flake8==3.7.9 pytest==5.2.2 pytest-cov==2.8.1 autopep8==1.4.4 -mypy==0.730 +mypy==0.740 py-spy==0.3.0 codecov==2.0.15 From 0e2194d683e739a6ebcc69f460bf6fed5c9f2fdd Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 28 Oct 2019 16:14:34 -0700 Subject: [PATCH 021/107] Update README.md (#152) * Update flags * Update debugging instructions and run instructions for develops * Update references to plugins directory * For readability add sections for run from command line using pip * Move internal doc under developer section * Add option to pass fully-qualified plugin path --- README.md | 345 +++++++++++++++++++---------------- helper/monitor_open_files.sh | 4 +- 2 files changed, 191 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 6602687067..50ff9ca488 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Table of Contents * [Development version](#development-version) * [Start proxy.py](#start-proxypy) * [From command line when installed using PIP](#from-command-line-when-installed-using-pip) + * [Run it](#run-it) + * [Reading logs](#reading-logs) + * [Enable DEBUG logging](#enable-debug-logging) * [From command line using repo source](#from-command-line-using-repo-source) * [Docker Image](#docker-image) * [Stable version](#stable-version-from-docker-hub) @@ -51,23 +54,22 @@ Table of Contents * [Plugin Ordering](#plugin-ordering) * [End-to-End Encryption](#end-to-end-encryption) * [TLS Interception](#tls-interception) -* [import proxy.py](#import-proxypy) - * [TCP Sockets](#tcp-sockets) - * [proxy.new_socket_connection](#proxynew_socket_connection) - * [proxy.socket_connection](#proxysocket_connection) - * [Http Client](#http-client) - * [proxy.build_http_request](#proxybuild_http_request) - * [proxy.build_http_response](#proxybuild_http_response) - * [Websocket Client](#websocket-client) - * [proxy.WebsocketFrame](#proxywebsocketframe) - * [proxy.WebsocketClient](#proxywebsocketclient) - * [Embed proxy.py](#embed-proxypy) +* [Embed proxy.py](#embed-proxypy) * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) - * [Start proxy.py from repo source](#start-proxypy-from-repo-source) * [Everything is a plugin](#everything-is-a-plugin) * [Internal Architecture](#internal-architecture) * [Internal Documentation](#internal-documentation) * [Sending a Pull Request](#sending-a-pull-request) + * [Utilities](#utilities) + * [TCP](#tcp-sockets) + * [new_socket_connection](#new_socket_connection) + * [socket_connection](#socket_connection) + * [Http](#http-client) + * [build_http_request](#build_http_request) + * [build_http_response](#build_http_response) + * [Websocket](#websocket) + * [WebsocketFrame](#websocketframe) + * [WebsocketClient](#websocketclient) * [Frequently Asked Questions](#frequently-asked-questions) * [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax) * [Unable to connect with proxy.py from remote host](#unable-to-connect-with-proxypy-from-remote-host) @@ -109,13 +111,15 @@ Features - No external dependency other than standard Python library - Programmable - Optionally enable builtin Web Server - - Customize proxy and http routing via [plugins](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples.py) - - Enable plugin using command line option e.g. `--plugins plugin_examples.CacheResponsesPlugin` - - Plugin API is currently in development state, expect breaking changes. + - Customize proxy and http routing via [plugins](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples) + - Enable plugin using command line option e.g. `--plugins plugin_examples/cache_responses.CacheResponsesPlugin` + - Plugin API is currently in development phase, expect breaking changes. - Realtime Dashboard - - Optionally enable bundled dashboard. Available at `http://localhost:8899/dashboard`. + - Optionally enable bundled dashboard. + - Available at `http://localhost:8899/dashboard`. - Inspect, Monitor, Control and Configure `proxy.py` at runtime. - Extend dashboard using plugins. + - Dashboard is currently in development phase, expect breaking changes. - Secure - Enable end-to-end encryption between clients and `proxy.py` using TLS - See [End-to-End Encryption](#end-to-end-encryption) @@ -157,6 +161,11 @@ Start proxy.py ## From command line when installed using PIP +When `proxy.py` is installed using `pip`, +an executable named `proxy` is added under your `$PATH`. + +#### Run it + Simply type `proxy` on command line to start it with default configuration. ``` @@ -166,6 +175,8 @@ $ proxy ...[redacted]... - Started server on ::1:8899 ``` +#### Reading logs + Things to notice from above logs: - `Loaded plugin` - `proxy.py` will load `proxy.http.proxy.HttpProxyPlugin` by default. @@ -181,6 +192,8 @@ Things to notice from above logs: - `Port 8899` - Use `--port` flag to customize default TCP port. +#### Enable DEBUG logging + All the logs above are `INFO` level logs, default `--log-level` for `proxy.py`. Lets start `proxy.py` with `DEBUG` level logging: @@ -203,18 +216,43 @@ See [flags](#flags) for full list of available configuration options. ## From command line using repo source -When `proxy.py` is installed using `pip`, -a binary file named `proxy` is added under the `bin` folder. - If you are trying to run `proxy.py` from source code, there is no binary file named `proxy` in the source code. -To start `proxy.py` from source code, use: -``` -$ git clone https://github.com/abhinavsingh/proxy.py.git -$ cd proxy.py -$ python -m proxy -``` +To start `proxy.py` from source code follow these instructions: + +- Clone repo + + ``` + $ git clone https://github.com/abhinavsingh/proxy.py.git + $ cd proxy.py + ``` + +- Create a Python 3 virtual env + + ``` + $ python3 -m venv venv + $ source venv/bin/activate + ``` + +- Install deps + + ``` + $ pip install -r requirements.txt + $ pip install -r requirements-testing.txt + ``` + +- Run tests + + ``` + $ make + ``` + +- Run proxy.py + + ``` + $ python -m proxy + ``` Also see [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) if you plan to work with `proxy.py` source code. @@ -232,7 +270,7 @@ if you plan to work with `proxy.py` source code. $ make container $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest -### Customize startup flags +#### Customize startup flags By default `docker` binary is started with IPv4 networking flags: @@ -670,121 +708,36 @@ cached file instead of plain text. Now use CA flags with other [plugin examples](#plugin-examples) to see them work with `https` traffic. -import proxy.py -=============== - -You can directly import `proxy.py` into your `Python` code. Example: - -``` -$ python ->>> import proxy ->>> -``` - -## TCP Sockets - -### proxy.new_socket_connection - -Attempts to create an IPv4 connection, then IPv6 and -finally a dual stack connection to provided address. - -``` ->>> conn = proxy.new_socket_connection(('httpbin.org', 80)) ->>> ...[ use connection ]... ->>> conn.close() -``` - -### proxy.socket_connection - -`socket_connection` is a convenient decorator + context manager -around `new_socket_connection` which ensures `conn.close` is implicit. - -As a context manager: - -``` ->>> with proxy.new_socket_connection(('httpbin.org', 80)) as conn: ->>> ... [ use connection ] ... -``` - -As a decorator: - -``` ->>> @proxy.new_socket_connection(('httpbin.org', 80)) ->>> def my_api_call(conn, *args, **kwargs): ->>> ... [ use connection ] ... -``` - -## Http Client - -### proxy.build_http_request - -##### Generate HTTP GET request - -``` ->>> proxy.build_http_request(b'GET', b'/') -b'GET / HTTP/1.1\r\n\r\n' ->>> -``` +Embed proxy.py +============== -##### Generate HTTP GET request with headers +To start `proxy.py` in embedded mode: ``` ->>> proxy.build_http_request(b'GET', b'/', - headers={b'Connection': b'close'}) -b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' ->>> -``` - -##### Generate HTTP POST request with headers and body +from proxy.main import main +if __name__ == '__main__': + main([]) ``` ->>> import json ->>> proxy.build_http_request(b'POST', b'/form', - headers={b'Content-type': b'application/json'}, - body=proxy.bytes_(json.dumps({'email': 'hello@world.com'}))) - b'POST /form HTTP/1.1\r\nContent-type: application/json\r\n\r\n{"email": "hello@world.com"}' -``` - -### proxy.build_http_response - -TODO - -## Websocket Client -### proxy.WebsocketFrame - -TODO - -### proxy.WebsocketClient - -TODO - -## Embed proxy.py - -To start `proxy.py` server from imported `proxy.py` module, simply do: +or, to start with arguments: ``` -import proxy +from proxy.main import main if __name__ == '__main__': - proxy.main(['--hostname', '::1', '--port', 8899]) + main([ + '--hostname', '::1', + '--port', '8899' + ]) ``` -See [Internal Documentation](#internal-documentation) -for all available classes and utility methods. - Plugin Developer and Contributor Guide ====================================== -## Start proxy.py from repo source - Contributors must start `proxy.py` from source to verify and develop new features / fixes. -Start `proxy.py` as: - - $ git clone https://github.com/abhinavsingh/proxy.py.git - $ cd proxy.py - $ python -m proxy +See [Run proxy.py from command line using repo source](#from-command-line-using-repo-source) for details. ## Everything is a plugin @@ -853,6 +806,86 @@ Every pull request goes through set of tests which must pass: weird formatting. But let's stick to one consistent formatting tool. I am open to flag changes for `autopep8`. +## Utilities + +## TCP Sockets + +#### new_socket_connection + +Attempts to create an IPv4 connection, then IPv6 and +finally a dual stack connection to provided address. + +``` +>>> conn = new_socket_connection(('httpbin.org', 80)) +>>> ...[ use connection ]... +>>> conn.close() +``` + +#### socket_connection + +`socket_connection` is a convenient decorator + context manager +around `new_socket_connection` which ensures `conn.close` is implicit. + +As a context manager: + +``` +>>> with socket_connection(('httpbin.org', 80)) as conn: +>>> ... [ use connection ] ... +``` + +As a decorator: + +``` +>>> @socket_connection(('httpbin.org', 80)) +>>> def my_api_call(conn, *args, **kwargs): +>>> ... [ use connection ] ... +``` + +## Http Client + +#### build_http_request + +##### Generate HTTP GET request + +``` +>>> build_http_request(b'GET', b'/') +b'GET / HTTP/1.1\r\n\r\n' +>>> +``` + +##### Generate HTTP GET request with headers + +``` +>>> build_http_request(b'GET', b'/', + headers={b'Connection': b'close'}) +b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' +>>> +``` + +##### Generate HTTP POST request with headers and body + +``` +>>> import json +>>> build_http_request(b'POST', b'/form', + headers={b'Content-type': b'application/json'}, + body=proxy.bytes_(json.dumps({'email': 'hello@world.com'}))) + b'POST /form HTTP/1.1\r\nContent-type: application/json\r\n\r\n{"email": "hello@world.com"}' +``` + +#### build_http_response + +TODO + +## Websocket + +#### WebsocketFrame + +TODO + +#### WebsocketClient + +TODO + ## Internal Documentation Browse through internal class hierarchy and documentation using `pydoc3`. @@ -912,13 +945,17 @@ for some background. Make sure your plugin modules are discoverable by adding them to `PYTHONPATH`. Example: -`PYTHONPATH=/path/to/my/app proxy.py --plugins my_app.proxyPlugin` +`PYTHONPATH=/path/to/my/app proxy --plugins my_app.proxyPlugin` ``` ...[redacted]... - Loaded plugin proxy.HttpProxyPlugin ...[redacted]... - Loaded plugin my_app.proxyPlugin ``` +or, make sure to pass fully-qualified path as parameter, e.g. + +`proxy --plugins /path/to/my/app/my_app.proxyPlugin` + ## GCE log viewer integration for proxy.py A starter [fluentd.conf](https://github.com/abhinavsingh/proxy.py/blob/develop/fluentd.conf) @@ -939,7 +976,8 @@ Now `proxy.py` logs can be browsed using ## ValueError: filedescriptor out of range in select -`proxy.py` is made to handle thousands of connections per second. +`proxy.py` is made to handle thousands of connections per second +without any socket leaks. 1. Make use of `--open-file-limit` flag to customize `ulimit -n`. - To set a value upper than the hard limit, run as root. @@ -949,42 +987,34 @@ If nothing helps, [open an issue](https://github.com/abhinavsingh/proxy.py/issue with `requests per second` sent and output of following debug script: ``` -# PID of proxy.py -PROXY_PY_PID=<... Put value here or use --pid-file option ...>; - -# Prints number of open files by main process -lsof -p $PROXY_PY_PID | wc -l; - -# Prints number of open files per worker process -pgrep -P $PROXY_PY_PID | while read pid; do lsof -p $pid | wc -l; done; +$ ./helper/monitor_open_files.sh ``` Flags ===== ``` -$ proxy.py -h -usage: proxy.py [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] - [--ca-key-file CA_KEY_FILE] [--ca-cert-dir CA_CERT_DIR] - [--ca-cert-file CA_CERT_FILE] - [--ca-signing-key-file CA_SIGNING_KEY_FILE] - [--cert-file CERT_FILE] - [--client-recvbuf-size CLIENT_RECVBUF_SIZE] - [--devtools-ws-path DEVTOOLS_WS_PATH] - [--disable-headers DISABLE_HEADERS] [--disable-http-proxy] - [--enable-devtools] [--enable-static-server] - [--enable-web-server] [--hostname HOSTNAME] - [--key-file KEY_FILE] [--log-level LOG_LEVEL] - [--log-file LOG_FILE] [--log-format LOG_FORMAT] - [--num-workers NUM_WORKERS] - [--open-file-limit OPEN_FILE_LIMIT] [--pac-file PAC_FILE] - [--pac-file-url-path PAC_FILE_URL_PATH] [--pid-file PID_FILE] - [--plugins PLUGINS] [--port PORT] - [--server-recvbuf-size SERVER_RECVBUF_SIZE] - [--static-server-dir STATIC_SERVER_DIR] [--threadless] - [--timeout TIMEOUT] [--version] - -proxy.py v1.2.0 +❯ proxy -h +usage: proxy [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] + [--ca-key-file CA_KEY_FILE] [--ca-cert-dir CA_CERT_DIR] + [--ca-cert-file CA_CERT_FILE] + [--ca-signing-key-file CA_SIGNING_KEY_FILE] + [--cert-file CERT_FILE] + [--client-recvbuf-size CLIENT_RECVBUF_SIZE] + [--devtools-ws-path DEVTOOLS_WS_PATH] + [--disable-headers DISABLE_HEADERS] [--disable-http-proxy] + [--enable-devtools] [--enable-events] [--enable-static-server] + [--enable-web-server] [--hostname HOSTNAME] [--key-file KEY_FILE] + [--log-level LOG_LEVEL] [--log-file LOG_FILE] + [--log-format LOG_FORMAT] [--num-workers NUM_WORKERS] + [--open-file-limit OPEN_FILE_LIMIT] [--pac-file PAC_FILE] + [--pac-file-url-path PAC_FILE_URL_PATH] [--pid-file PID_FILE] + [--plugins PLUGINS] [--port PORT] + [--server-recvbuf-size SERVER_RECVBUF_SIZE] + [--static-server-dir STATIC_SERVER_DIR] [--threadless] + [--timeout TIMEOUT] [--version] + +proxy.py v2.0.0 optional arguments: -h, --help show this help message and exit @@ -1029,6 +1059,9 @@ optional arguments: proxy.HttpProxyPlugin. --enable-devtools Default: False. Enables integration with Chrome Devtool Frontend. + --enable-events Default: False. Enables core to dispatch lifecycle + events. Plugins can be used to subscribe for core + events. --enable-static-server Default: False. Enable inbuilt static file server. Optionally, also use --static-server-dir to serve diff --git a/helper/monitor_open_files.sh b/helper/monitor_open_files.sh index a5001d002b..7bc208594f 100755 --- a/helper/monitor_open_files.sh +++ b/helper/monitor_open_files.sh @@ -8,13 +8,13 @@ # :license: BSD, see LICENSE for more details. # # Usage -# ./monitor +# ./monitor_open_files # # Alternately, just run: # watch -n 1 'lsof -i TCP:8899 | grep -v LISTEN' PROXY_PY_PID=$1 -if [ -z "$PROXY_PY_PID" ]; then +if [[ -z "$PROXY_PY_PID" ]]; then echo "PROXY_PY_PID required as argument." exit 1 fi From 75a818d397986ec766d37b9d986204b9fbbcfd40 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 30 Oct 2019 03:43:24 +0200 Subject: [PATCH 022/107] Update setuptools from 41.5.1 to 41.6.0 (#153) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index 6b145d61ce..25bc4ab114 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==2.0.0 wheel==0.33.6 -setuptools==41.5.1 +setuptools==41.6.0 From 3aa1dc28245c3d8e289424a261a805c7aed3eefa Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 29 Oct 2019 20:41:39 -0700 Subject: [PATCH 023/107] Test refactor + Docker image CI (#154) * Move tests into individual modules too * Ensure one test class per file * Fix docker image after refactoring * Add github actions workflow for building docker image * Fix image name * Setup python required for extracting proxy version * Version will also require deps --- .dockerignore | 9 +- .github/workflows/test-docker.yml | 28 +++ .github/workflows/test-library.yml | 1 + .gitignore | 13 +- Dockerfile | 16 +- Makefile | 93 +++++---- README.md | 5 +- benchmark/benchmark.py | 3 +- dashboard/dashboard.py | 3 +- dashboard/src/devtools.ts | 3 +- dashboard/src/proxy.css | 9 + dashboard/src/proxy.html | 9 + dashboard/src/proxy.ts | 3 +- dashboard/test/test.ts | 10 + helper/Procfile | 3 +- helper/chrome_with_proxy.sh | 3 +- helper/fluentd.conf | 3 +- helper/monitor_open_files.sh | 3 +- plugin_examples/__init__.py | 3 +- plugin_examples/cache_responses.py | 3 +- plugin_examples/filter_by_upstream.py | 3 +- plugin_examples/man_in_the_middle.py | 3 +- plugin_examples/mock_rest_api.py | 3 +- plugin_examples/modify_post_data.py | 3 +- plugin_examples/redirect_to_custom_server.py | 3 +- plugin_examples/shortlink.py | 3 +- plugin_examples/web_server_route.py | 3 +- tests/__init__.py | 3 +- tests/common/__init__.py | 9 + tests/{ => common}/test_text_bytes.py | 0 tests/{ => common}/test_utils.py | 0 tests/core/__init__.py | 9 + tests/{ => core}/test_acceptor.py | 52 +---- tests/core/test_acceptor_pool.py | 56 +++++ tests/{ => core}/test_connection.py | 0 tests/http/__init__.py | 9 + tests/{ => http}/test_chunk_parser.py | 0 tests/{ => http}/test_http_parser.py | 0 tests/{ => http}/test_http_proxy.py | 0 tests/{ => http}/test_http_proxy_examples.py | 196 +----------------- ...tp_proxy_examples_with_tls_interception.py | 192 +++++++++++++++++ .../test_http_proxy_tls_interception.py | 0 .../{ => http}/test_http_request_rejected.py | 0 tests/{ => http}/test_protocol_handler.py | 0 tests/{ => http}/test_web_server.py | 0 tests/{ => http}/test_websocket_client.py | 0 tests/{ => http}/test_websocket_frame.py | 0 tests/http/utils.py | 26 +++ 48 files changed, 473 insertions(+), 323 deletions(-) create mode 100644 .github/workflows/test-docker.yml create mode 100644 tests/common/__init__.py rename tests/{ => common}/test_text_bytes.py (100%) rename tests/{ => common}/test_utils.py (100%) create mode 100644 tests/core/__init__.py rename tests/{ => core}/test_acceptor.py (63%) create mode 100644 tests/core/test_acceptor_pool.py rename tests/{ => core}/test_connection.py (100%) create mode 100644 tests/http/__init__.py rename tests/{ => http}/test_chunk_parser.py (100%) rename tests/{ => http}/test_http_parser.py (100%) rename tests/{ => http}/test_http_proxy.py (100%) rename tests/{ => http}/test_http_proxy_examples.py (54%) create mode 100644 tests/http/test_http_proxy_examples_with_tls_interception.py rename tests/{ => http}/test_http_proxy_tls_interception.py (100%) rename tests/{ => http}/test_http_request_rejected.py (100%) rename tests/{ => http}/test_protocol_handler.py (100%) rename tests/{ => http}/test_web_server.py (100%) rename tests/{ => http}/test_websocket_client.py (100%) rename tests/{ => http}/test_websocket_frame.py (100%) create mode 100644 tests/http/utils.py diff --git a/.dockerignore b/.dockerignore index 45aff3ba7c..32e6c2a0cd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,11 @@ # Ignore everything ** -# Except proxy.py -!proxy.py +# Except proxy +!proxy !requirements.txt +!setup.py +!README.md + +# Ignore __pycache__ directory +proxy/__pycache__ diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml new file mode 100644 index 0000000000..b50056e8b4 --- /dev/null +++ b/.github/workflows/test-docker.yml @@ -0,0 +1,28 @@ +name: Proxy.py Docker + +on: [push] + +jobs: + build: + runs-on: ${{ matrix.os }} + name: Python ${{ matrix.python }} on ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python: [3.7] + max-parallel: 1 + fail-fast: false + steps: + - uses: actions/checkout@v1 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python }}-dev + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-testing.txt + - name: Build + run: | + make container diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index cc617014ee..24c575a1ce 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -21,6 +21,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -r requirements.txt pip install -r requirements-testing.txt - name: Quality Check run: | diff --git a/.gitignore b/.gitignore index 25ef618ef7..dbb13eff17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,18 @@ -.coverage* +.coverage .idea .vscode .project .pydevproject .settings .mypy_cache +.hypothesis + coverage.xml +proxy.py.iml +*.pyc +ca-*.pem +https-*.pem + node_modules venv cover @@ -13,7 +20,3 @@ htmlcov dist build proxy.py.egg-info -proxy.py.iml -*.pyc -ca-*.pem -https-*.pem diff --git a/Dockerfile b/Dockerfile index c0378f2189..3b94c1de30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,25 @@ FROM python:3.7-alpine as base FROM base as builder -COPY requirements.txt . -RUN pip install --upgrade pip && pip install --install-option="--prefix=/deps" -r requirements.txt +COPY requirements.txt /app/ +COPY setup.py /app/ +COPY README.md /app/ +COPY proxy/ /app/proxy/ +WORKDIR /app +RUN pip install --upgrade pip && \ + pip install --install-option="--prefix=/deps" . FROM base LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ - com.abhinavsingh.description="⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file" \ + com.abhinavsingh.description="⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable proxy server for Application debugging, testing and development" \ com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py" COPY --from=builder /deps /usr/local -COPY proxy.py /app/ -WORKDIR /app + EXPOSE 8899/tcp -ENTRYPOINT [ "./proxy.py" ] +ENTRYPOINT [ "proxy" ] CMD [ "--hostname=0.0.0.0", \ "--port=8899" ] diff --git a/Makefile b/Makefile index 1beefd5a27..0286330cfe 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,31 @@ CA_KEY_FILE_PATH := ca-key.pem CA_CERT_FILE_PATH := ca-cert.pem CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem -.PHONY: all clean test package test-release release coverage lint autopep8 +.PHONY: all clean-lib test-lib package test-release release coverage lint autopep8 .PHONY: container run-container release-container https-certificates ca-certificates .PHONY: profile dashboard clean-dashboard -all: clean test +all: clean-lib test-lib -clean: +autopep8: + autopep8 --recursive --in-place --aggressive proxy/*.py + autopep8 --recursive --in-place --aggressive proxy/*/*.py + autopep8 --recursive --in-place --aggressive tests/*.py + autopep8 --recursive --in-place --aggressive plugin_examples/*.py + autopep8 --recursive --in-place --aggressive benchmark/*.py + autopep8 --recursive --in-place --aggressive dashboard/*.py + autopep8 --recursive --in-place --aggressive setup.py + +ca-certificates: + # Generate CA key + openssl genrsa -out $(CA_KEY_FILE_PATH) 2048 + # Generate CA certificate + openssl req -new -x509 -days 3650 -key $(CA_KEY_FILE_PATH) -out $(CA_CERT_FILE_PATH) + # Generate key that will be used to generate domain certificates on the fly + # Generated certificates are then signed with CA certificate / key generated above + openssl genrsa -out $(CA_SIGNING_KEY_FILE_PATH) 2048 + +clean-lib: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + @@ -29,66 +47,49 @@ clean: rm -rf build rm -rf proxy.py.egg-info rm -rf .pytest_cache + rm -rf .hypothesis -test: lint - python -m unittest tests/*.py - -package: clean - python setup.py sdist bdist_wheel - -test-release: package - twine upload --verbose --repository-url https://test.pypi.org/legacy/ dist/* +clean-dashboard: + rm -rf public/dashboard -release: package - twine upload dist/* +container: + docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . coverage: pytest --cov=proxy --cov-report=html tests/ open htmlcov/index.html +dashboard: + pushd dashboard && npm run build && popd + +https-certificates: + # Generate server key + openssl genrsa -out $(HTTPS_KEY_FILE_PATH) 2048 + # Generate server certificate + openssl req -new -x509 -days 3650 -key $(HTTPS_KEY_FILE_PATH) -out $(HTTPS_CERT_FILE_PATH) + lint: flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py -autopep8: - autopep8 --recursive --in-place --aggressive proxy/*.py - autopep8 --recursive --in-place --aggressive proxy/*/*.py - autopep8 --recursive --in-place --aggressive tests/*.py - autopep8 --recursive --in-place --aggressive plugin_examples/*.py - autopep8 --recursive --in-place --aggressive benchmark/*.py - autopep8 --recursive --in-place --aggressive dashboard/*.py - autopep8 --recursive --in-place --aggressive setup.py +package: clean + python setup.py sdist bdist_wheel -container: - docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . +profile: + sudo py-spy -F -f profile.svg -d 3600 proxy.py -run-container: - docker run -it -p 8899:8899 --rm $(LATEST_TAG) +release: package + twine upload dist/* release-container: docker push $(IMAGE_TAG) docker push $(LATEST_TAG) -https-certificates: - # Generate server key - openssl genrsa -out $(HTTPS_KEY_FILE_PATH) 2048 - # Generate server certificate - openssl req -new -x509 -days 3650 -key $(HTTPS_KEY_FILE_PATH) -out $(HTTPS_CERT_FILE_PATH) - -ca-certificates: - # Generate CA key - openssl genrsa -out $(CA_KEY_FILE_PATH) 2048 - # Generate CA certificate - openssl req -new -x509 -days 3650 -key $(CA_KEY_FILE_PATH) -out $(CA_CERT_FILE_PATH) - # Generate key that will be used to generate domain certificates on the fly - # Generated certificates are then signed with CA certificate / key generated above - openssl genrsa -out $(CA_SIGNING_KEY_FILE_PATH) 2048 - -profile: - sudo py-spy -F -f profile.svg -d 3600 proxy.py +run-container: + docker run -it -p 8899:8899 --rm $(LATEST_TAG) -dashboard: - pushd dashboard && npm run build && popd +test-lib: lint + python -m unittest discover -clean-dashboard: - rm -rf public/dashboard +test-release: package + twine upload --verbose --repository-url https://test.pypi.org/legacy/ dist/* diff --git a/README.md b/README.md index 50ff9ca488..5f60898eee 100644 --- a/README.md +++ b/README.md @@ -1119,14 +1119,15 @@ Changelog - `v2.x` - No longer ~~a single file module~~. + - Added support for threadless execution. - Added dashboard app. - `v1.x` - `Python3` only. - Deprecated support for ~~Python 2.x~~. - - Added support for multi accept. + - Added support multi core accept. - Added plugin support. - `v0.x` - Single file. - Single threaded server. -For detailed changelog refer +For detailed changelog refer either to release PRs or commit history. diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 317d21b380..1a75fd83fa 100755 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -3,7 +3,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index 1995de8b85..b2ea55d86f 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -1,7 +1,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/src/devtools.ts b/dashboard/src/devtools.ts index 91c1f2527e..595c0aacde 100644 --- a/dashboard/src/devtools.ts +++ b/dashboard/src/devtools.ts @@ -1,7 +1,8 @@ /* proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css index de703af6bc..af99872d24 100644 --- a/dashboard/src/proxy.css +++ b/dashboard/src/proxy.css @@ -1,3 +1,12 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. + */ #app { background-color: #eeeeee; height: 100%; diff --git a/dashboard/src/proxy.html b/dashboard/src/proxy.html index 34ab8dd5cc..d7338c5e4a 100644 --- a/dashboard/src/proxy.html +++ b/dashboard/src/proxy.html @@ -1,3 +1,12 @@ + diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index 7c89aaaaa5..b21eb0cb1e 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -1,7 +1,8 @@ /* proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/test/test.ts b/dashboard/test/test.ts index 97a651f819..b7b3369c0c 100644 --- a/dashboard/test/test.ts +++ b/dashboard/test/test.ts @@ -1,3 +1,13 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ + import { ProxyDashboard } from "../src/proxy"; describe("test suite", () => { diff --git a/helper/Procfile b/helper/Procfile index bcfb16a577..4d7d292ec9 100644 --- a/helper/Procfile +++ b/helper/Procfile @@ -1,6 +1,7 @@ # proxy.py # ~~~~~~~~ -# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable +# proxy server for Application debugging, testing and development. # # :copyright: (c) 2013-present by Abhinav Singh and contributors. # :license: BSD, see LICENSE for more details. diff --git a/helper/chrome_with_proxy.sh b/helper/chrome_with_proxy.sh index 8f3cf3d242..fb4760b0c8 100755 --- a/helper/chrome_with_proxy.sh +++ b/helper/chrome_with_proxy.sh @@ -2,7 +2,8 @@ # proxy.py # ~~~~~~~~ -# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable +# proxy server for Application debugging, testing and development. # # :copyright: (c) 2013-present by Abhinav Singh and contributors. # :license: BSD, see LICENSE for more details. diff --git a/helper/fluentd.conf b/helper/fluentd.conf index dc34a9557f..d5d25b2616 100644 --- a/helper/fluentd.conf +++ b/helper/fluentd.conf @@ -1,6 +1,7 @@ # proxy.py # ~~~~~~~~ -# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable +# proxy server for Application debugging, testing and development. # # :copyright: (c) 2013-present by Abhinav Singh and contributors. # :license: BSD, see LICENSE for more details. diff --git a/helper/monitor_open_files.sh b/helper/monitor_open_files.sh index 7bc208594f..7bfa48c631 100755 --- a/helper/monitor_open_files.sh +++ b/helper/monitor_open_files.sh @@ -2,7 +2,8 @@ # proxy.py # ~~~~~~~~ -# ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. +# ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable +# proxy server for Application debugging, testing and development. # # :copyright: (c) 2013-present by Abhinav Singh and contributors. # :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/__init__.py b/plugin_examples/__init__.py index ba034136b9..87af91833d 100644 --- a/plugin_examples/__init__.py +++ b/plugin_examples/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/cache_responses.py b/plugin_examples/cache_responses.py index 380cedfc69..797a247b31 100644 --- a/plugin_examples/cache_responses.py +++ b/plugin_examples/cache_responses.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/filter_by_upstream.py b/plugin_examples/filter_by_upstream.py index 09b55bd39a..5dbe0fd78e 100644 --- a/plugin_examples/filter_by_upstream.py +++ b/plugin_examples/filter_by_upstream.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/man_in_the_middle.py b/plugin_examples/man_in_the_middle.py index 2f803fc0e0..f083ce89e9 100644 --- a/plugin_examples/man_in_the_middle.py +++ b/plugin_examples/man_in_the_middle.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/mock_rest_api.py b/plugin_examples/mock_rest_api.py index 615ca880cb..2eab7f15b4 100644 --- a/plugin_examples/mock_rest_api.py +++ b/plugin_examples/mock_rest_api.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/modify_post_data.py b/plugin_examples/modify_post_data.py index 0dfb63a71a..324742ec85 100644 --- a/plugin_examples/modify_post_data.py +++ b/plugin_examples/modify_post_data.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/redirect_to_custom_server.py b/plugin_examples/redirect_to_custom_server.py index f945fcd2c8..d93a2d85bb 100644 --- a/plugin_examples/redirect_to_custom_server.py +++ b/plugin_examples/redirect_to_custom_server.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/shortlink.py b/plugin_examples/shortlink.py index a44d453093..70d5b270b4 100644 --- a/plugin_examples/shortlink.py +++ b/plugin_examples/shortlink.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/plugin_examples/web_server_route.py b/plugin_examples/web_server_route.py index 76635624d1..ffb4aa65c7 100644 --- a/plugin_examples/web_server_route.py +++ b/plugin_examples/web_server_route.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/__init__.py b/tests/__init__.py index 3fd4cfafd4..81b1532d33 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/tests/common/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/test_text_bytes.py b/tests/common/test_text_bytes.py similarity index 100% rename from tests/test_text_bytes.py rename to tests/common/test_text_bytes.py diff --git a/tests/test_utils.py b/tests/common/test_utils.py similarity index 100% rename from tests/test_utils.py rename to tests/common/test_utils.py diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/test_acceptor.py b/tests/core/test_acceptor.py similarity index 63% rename from tests/test_acceptor.py rename to tests/core/test_acceptor.py index b539aab0d8..db3a3a3b6f 100644 --- a/tests/test_acceptor.py +++ b/tests/core/test_acceptor.py @@ -14,7 +14,7 @@ from unittest import mock from proxy.common.flags import Flags -from proxy.core.acceptor import Acceptor, AcceptorPool +from proxy.core.acceptor import Acceptor class TestAcceptor(unittest.TestCase): @@ -95,53 +95,3 @@ def test_accepts_client_from_server_socket( target=self.mock_protocol_handler.return_value.run) mock_thread.return_value.start.assert_called() sock.close.assert_called() - - -class TestAcceptorPool(unittest.TestCase): - - @mock.patch('proxy.core.acceptor.send_handle') - @mock.patch('multiprocessing.Pipe') - @mock.patch('socket.socket') - @mock.patch('proxy.core.acceptor.Acceptor') - def test_setup_and_shutdown( - self, - mock_worker: mock.Mock, - mock_socket: mock.Mock, - mock_pipe: mock.Mock, - _mock_send_handle: mock.Mock) -> None: - mock_worker1 = mock.MagicMock() - mock_worker2 = mock.MagicMock() - mock_worker.side_effect = [mock_worker1, mock_worker2] - - num_workers = 2 - sock = mock_socket.return_value - work_klass = mock.MagicMock() - flags = Flags(num_workers=2) - acceptor = AcceptorPool(flags=flags, work_klass=work_klass) - - acceptor.setup() - - work_klass.assert_not_called() - mock_socket.assert_called_with( - socket.AF_INET6 if acceptor.flags.hostname.version == 6 else socket.AF_INET, - socket.SOCK_STREAM - ) - sock.setsockopt.assert_called_with( - socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind.assert_called_with( - (str(acceptor.flags.hostname), acceptor.flags.port)) - sock.listen.assert_called_with(acceptor.flags.backlog) - sock.setblocking.assert_called_with(False) - - self.assertTrue(mock_pipe.call_count, num_workers) - self.assertTrue(mock_worker.call_count, num_workers) - mock_worker1.start.assert_called() - mock_worker1.join.assert_not_called() - mock_worker2.start.assert_called() - mock_worker2.join.assert_not_called() - - sock.close.assert_called() - - acceptor.shutdown() - mock_worker1.join.assert_called() - mock_worker2.join.assert_called() diff --git a/tests/core/test_acceptor_pool.py b/tests/core/test_acceptor_pool.py new file mode 100644 index 0000000000..9c9ceba1af --- /dev/null +++ b/tests/core/test_acceptor_pool.py @@ -0,0 +1,56 @@ +import unittest +import socket +from unittest import mock + +from proxy.common.flags import Flags +from proxy.core.acceptor import AcceptorPool + + +class TestAcceptorPool(unittest.TestCase): + + @mock.patch('proxy.core.acceptor.send_handle') + @mock.patch('multiprocessing.Pipe') + @mock.patch('socket.socket') + @mock.patch('proxy.core.acceptor.Acceptor') + def test_setup_and_shutdown( + self, + mock_worker: mock.Mock, + mock_socket: mock.Mock, + mock_pipe: mock.Mock, + _mock_send_handle: mock.Mock) -> None: + mock_worker1 = mock.MagicMock() + mock_worker2 = mock.MagicMock() + mock_worker.side_effect = [mock_worker1, mock_worker2] + + num_workers = 2 + sock = mock_socket.return_value + work_klass = mock.MagicMock() + flags = Flags(num_workers=2) + acceptor = AcceptorPool(flags=flags, work_klass=work_klass) + + acceptor.setup() + + work_klass.assert_not_called() + mock_socket.assert_called_with( + socket.AF_INET6 if acceptor.flags.hostname.version == 6 else socket.AF_INET, + socket.SOCK_STREAM + ) + sock.setsockopt.assert_called_with( + socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind.assert_called_with( + (str(acceptor.flags.hostname), acceptor.flags.port)) + sock.listen.assert_called_with(acceptor.flags.backlog) + sock.setblocking.assert_called_with(False) + + self.assertTrue(mock_pipe.call_count, num_workers) + self.assertTrue(mock_worker.call_count, num_workers) + mock_worker1.start.assert_called() + mock_worker1.join.assert_not_called() + mock_worker2.start.assert_called() + mock_worker2.join.assert_not_called() + + sock.close.assert_called() + + acceptor.shutdown() + mock_worker1.join.assert_called() + mock_worker2.join.assert_called() diff --git a/tests/test_connection.py b/tests/core/test_connection.py similarity index 100% rename from tests/test_connection.py rename to tests/core/test_connection.py diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/tests/http/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/test_chunk_parser.py b/tests/http/test_chunk_parser.py similarity index 100% rename from tests/test_chunk_parser.py rename to tests/http/test_chunk_parser.py diff --git a/tests/test_http_parser.py b/tests/http/test_http_parser.py similarity index 100% rename from tests/test_http_parser.py rename to tests/http/test_http_parser.py diff --git a/tests/test_http_proxy.py b/tests/http/test_http_proxy.py similarity index 100% rename from tests/test_http_proxy.py rename to tests/http/test_http_proxy.py diff --git a/tests/test_http_proxy_examples.py b/tests/http/test_http_proxy_examples.py similarity index 54% rename from tests/test_http_proxy_examples.py rename to tests/http/test_http_proxy_examples.py index 19bf6496f9..b90eba2427 100644 --- a/tests/test_http_proxy_examples.py +++ b/tests/http/test_http_proxy_examples.py @@ -9,45 +9,23 @@ """ import unittest import selectors -import ssl -import socket import json from urllib import parse as urlparse from unittest import mock -from typing import Type, cast, Any +from typing import cast from proxy.common.flags import Flags from proxy.http.handler import HttpProtocolHandler -from proxy.http.proxy import HttpProxyBasePlugin, HttpProxyPlugin +from proxy.http.proxy import HttpProxyPlugin from proxy.common.utils import build_http_request, bytes_, build_http_response from proxy.common.constants import PROXY_AGENT_HEADER_VALUE from proxy.http.codes import httpStatusCodes -from proxy.http.methods import httpMethods -from plugin_examples import modify_post_data from plugin_examples import mock_rest_api from plugin_examples import redirect_to_custom_server -from plugin_examples import filter_by_upstream -from plugin_examples import cache_responses -from plugin_examples import man_in_the_middle - -def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]: - plugin: Type[HttpProxyBasePlugin] = modify_post_data.ModifyPostDataPlugin - if test_name == 'test_modify_post_data_plugin': - plugin = modify_post_data.ModifyPostDataPlugin - elif test_name == 'test_proposed_rest_api_plugin': - plugin = mock_rest_api.ProposedRestApiPlugin - elif test_name == 'test_redirect_to_custom_server_plugin': - plugin = redirect_to_custom_server.RedirectToCustomServerPlugin - elif test_name == 'test_filter_by_upstream_host_plugin': - plugin = filter_by_upstream.FilterByUpstreamHostPlugin - elif test_name == 'test_cache_responses_plugin': - plugin = cache_responses.CacheResponsesPlugin - elif test_name == 'test_man_in_the_middle_plugin': - plugin = man_in_the_middle.ManInTheMiddlePlugin - return plugin +from .utils import get_plugin_by_test_name class TestHttpProxyPluginExamples(unittest.TestCase): @@ -274,171 +252,3 @@ def closed() -> bool: httpStatusCodes.OK, reason=b'OK', body=b'Hello from man in the middle') ) - - -class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): - - @mock.patch('ssl.wrap_socket') - @mock.patch('ssl.create_default_context') - @mock.patch('proxy.http.proxy.TcpServerConnection') - @mock.patch('subprocess.Popen') - @mock.patch('selectors.DefaultSelector') - @mock.patch('socket.fromfd') - def setUp(self, - mock_fromfd: mock.Mock, - mock_selector: mock.Mock, - mock_popen: mock.Mock, - mock_server_conn: mock.Mock, - mock_ssl_context: mock.Mock, - mock_ssl_wrap: mock.Mock) -> None: - self.mock_fromfd = mock_fromfd - self.mock_selector = mock_selector - self.mock_popen = mock_popen - self.mock_server_conn = mock_server_conn - self.mock_ssl_context = mock_ssl_context - self.mock_ssl_wrap = mock_ssl_wrap - - self.fileno = 10 - self._addr = ('127.0.0.1', 54382) - self.flags = Flags( - ca_cert_file='ca-cert.pem', - ca_key_file='ca-key.pem', - ca_signing_key_file='ca-signing-key.pem',) - self.plugin = mock.MagicMock() - - plugin = get_plugin_by_test_name(self._testMethodName) - - self.flags.plugins = { - b'HttpProtocolHandlerPlugin': [HttpProxyPlugin], - b'HttpProxyBasePlugin': [plugin], - } - self._conn = mock.MagicMock(spec=socket.socket) - mock_fromfd.return_value = self._conn - self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) - self.protocol_handler.initialize() - - self.server = self.mock_server_conn.return_value - - self.server_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) - self.mock_ssl_context.return_value.wrap_socket.return_value = self.server_ssl_connection - self.client_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) - self.mock_ssl_wrap.return_value = self.client_ssl_connection - - def has_buffer() -> bool: - return cast(bool, self.server.queue.called) - - def closed() -> bool: - return not self.server.connect.called - - def mock_connection() -> Any: - if self.mock_ssl_context.return_value.wrap_socket.called: - return self.server_ssl_connection - return self._conn - - self.server.has_buffer.side_effect = has_buffer - type(self.server).closed = mock.PropertyMock(side_effect=closed) - type( - self.server).connection = mock.PropertyMock( - side_effect=mock_connection) - - self.mock_selector.return_value.select.side_effect = [ - [(selectors.SelectorKey( - fileobj=self._conn, - fd=self._conn.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=self.client_ssl_connection, - fd=self.client_ssl_connection.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], - [(selectors.SelectorKey( - fileobj=self.server_ssl_connection, - fd=self.server_ssl_connection.fileno, - events=selectors.EVENT_WRITE, - data=None), selectors.EVENT_WRITE)], - [(selectors.SelectorKey( - fileobj=self.server_ssl_connection, - fd=self.server_ssl_connection.fileno, - events=selectors.EVENT_READ, - data=None), selectors.EVENT_READ)], ] - - # Connect - def send(raw: bytes) -> int: - return len(raw) - - self._conn.send.side_effect = send - self._conn.recv.return_value = build_http_request( - httpMethods.CONNECT, b'uni.corn:443' - ) - self.protocol_handler.run_once() - - self.mock_popen.assert_called() - self.mock_server_conn.assert_called_once_with('uni.corn', 443) - self.server.connect.assert_called() - self.assertEqual( - self.protocol_handler.client.connection, - self.client_ssl_connection) - self.assertEqual(self.server.connection, self.server_ssl_connection) - self._conn.send.assert_called_with( - HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT - ) - self.assertEqual(self.protocol_handler.client.buffer, b'') - - def test_modify_post_data_plugin(self) -> None: - original = b'{"key": "value"}' - modified = b'{"key": "modified"}' - self.client_ssl_connection.recv.return_value = build_http_request( - b'POST', b'/', - headers={ - b'Host': b'uni.corn', - b'Content-Type': b'application/x-www-form-urlencoded', - b'Content-Length': bytes_(len(original)), - }, - body=original - ) - self.protocol_handler.run_once() - self.server.queue.assert_called_with( - build_http_request( - b'POST', b'/', - headers={ - b'Host': b'uni.corn', - b'Content-Length': bytes_(len(modified)), - b'Content-Type': b'application/json', - }, - body=modified - ) - ) - - @mock.patch('proxy.http.proxy.TcpServerConnection') - def test_man_in_the_middle_plugin( - self, mock_server_conn: mock.Mock) -> None: - request = build_http_request( - b'GET', b'/', - headers={ - b'Host': b'uni.corn', - } - ) - self.client_ssl_connection.recv.return_value = request - - # Client read - self.protocol_handler.run_once() - self.server.queue.assert_called_once_with(request) - - # Server write - self.protocol_handler.run_once() - self.server.flush.assert_called_once() - - # Server read - self.server.recv.return_value = \ - build_http_response( - httpStatusCodes.OK, - reason=b'OK', body=b'Original Response From Upstream') - self.protocol_handler.run_once() - self.assertEqual( - self.protocol_handler.client.buffer, - build_http_response( - httpStatusCodes.OK, - reason=b'OK', body=b'Hello from man in the middle') - ) diff --git a/tests/http/test_http_proxy_examples_with_tls_interception.py b/tests/http/test_http_proxy_examples_with_tls_interception.py new file mode 100644 index 0000000000..efe781c8f2 --- /dev/null +++ b/tests/http/test_http_proxy_examples_with_tls_interception.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import socket +import selectors +import ssl + +from unittest import mock +from typing import Any, cast + +from proxy.common.utils import bytes_ +from proxy.common.flags import Flags +from proxy.common.utils import build_http_request, build_http_response +from proxy.http.codes import httpStatusCodes +from proxy.http.methods import httpMethods +from proxy.http.handler import HttpProtocolHandler +from proxy.http.proxy import HttpProxyPlugin + +from .utils import get_plugin_by_test_name + + +class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): + + @mock.patch('ssl.wrap_socket') + @mock.patch('ssl.create_default_context') + @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('subprocess.Popen') + @mock.patch('selectors.DefaultSelector') + @mock.patch('socket.fromfd') + def setUp(self, + mock_fromfd: mock.Mock, + mock_selector: mock.Mock, + mock_popen: mock.Mock, + mock_server_conn: mock.Mock, + mock_ssl_context: mock.Mock, + mock_ssl_wrap: mock.Mock) -> None: + self.mock_fromfd = mock_fromfd + self.mock_selector = mock_selector + self.mock_popen = mock_popen + self.mock_server_conn = mock_server_conn + self.mock_ssl_context = mock_ssl_context + self.mock_ssl_wrap = mock_ssl_wrap + + self.fileno = 10 + self._addr = ('127.0.0.1', 54382) + self.flags = Flags( + ca_cert_file='ca-cert.pem', + ca_key_file='ca-key.pem', + ca_signing_key_file='ca-signing-key.pem',) + self.plugin = mock.MagicMock() + + plugin = get_plugin_by_test_name(self._testMethodName) + + self.flags.plugins = { + b'HttpProtocolHandlerPlugin': [HttpProxyPlugin], + b'HttpProxyBasePlugin': [plugin], + } + self._conn = mock.MagicMock(spec=socket.socket) + mock_fromfd.return_value = self._conn + self.protocol_handler = HttpProtocolHandler( + self.fileno, self._addr, flags=self.flags) + self.protocol_handler.initialize() + + self.server = self.mock_server_conn.return_value + + self.server_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_context.return_value.wrap_socket.return_value = self.server_ssl_connection + self.client_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket) + self.mock_ssl_wrap.return_value = self.client_ssl_connection + + def has_buffer() -> bool: + return cast(bool, self.server.queue.called) + + def closed() -> bool: + return not self.server.connect.called + + def mock_connection() -> Any: + if self.mock_ssl_context.return_value.wrap_socket.called: + return self.server_ssl_connection + return self._conn + + self.server.has_buffer.side_effect = has_buffer + type(self.server).closed = mock.PropertyMock(side_effect=closed) + type( + self.server).connection = mock.PropertyMock( + side_effect=mock_connection) + + self.mock_selector.return_value.select.side_effect = [ + [(selectors.SelectorKey( + fileobj=self._conn, + fd=self._conn.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self.client_ssl_connection, + fd=self.client_ssl_connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], + [(selectors.SelectorKey( + fileobj=self.server_ssl_connection, + fd=self.server_ssl_connection.fileno, + events=selectors.EVENT_WRITE, + data=None), selectors.EVENT_WRITE)], + [(selectors.SelectorKey( + fileobj=self.server_ssl_connection, + fd=self.server_ssl_connection.fileno, + events=selectors.EVENT_READ, + data=None), selectors.EVENT_READ)], ] + + # Connect + def send(raw: bytes) -> int: + return len(raw) + + self._conn.send.side_effect = send + self._conn.recv.return_value = build_http_request( + httpMethods.CONNECT, b'uni.corn:443' + ) + self.protocol_handler.run_once() + + self.mock_popen.assert_called() + self.mock_server_conn.assert_called_once_with('uni.corn', 443) + self.server.connect.assert_called() + self.assertEqual( + self.protocol_handler.client.connection, + self.client_ssl_connection) + self.assertEqual(self.server.connection, self.server_ssl_connection) + self._conn.send.assert_called_with( + HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT + ) + self.assertEqual(self.protocol_handler.client.buffer, b'') + + def test_modify_post_data_plugin(self) -> None: + original = b'{"key": "value"}' + modified = b'{"key": "modified"}' + self.client_ssl_connection.recv.return_value = build_http_request( + b'POST', b'/', + headers={ + b'Host': b'uni.corn', + b'Content-Type': b'application/x-www-form-urlencoded', + b'Content-Length': bytes_(len(original)), + }, + body=original + ) + self.protocol_handler.run_once() + self.server.queue.assert_called_with( + build_http_request( + b'POST', b'/', + headers={ + b'Host': b'uni.corn', + b'Content-Length': bytes_(len(modified)), + b'Content-Type': b'application/json', + }, + body=modified + ) + ) + + def test_man_in_the_middle_plugin(self) -> None: + request = build_http_request( + b'GET', b'/', + headers={ + b'Host': b'uni.corn', + } + ) + self.client_ssl_connection.recv.return_value = request + + # Client read + self.protocol_handler.run_once() + self.server.queue.assert_called_once_with(request) + + # Server write + self.protocol_handler.run_once() + self.server.flush.assert_called_once() + + # Server read + self.server.recv.return_value = \ + build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Original Response From Upstream') + self.protocol_handler.run_once() + self.assertEqual( + self.protocol_handler.client.buffer, + build_http_response( + httpStatusCodes.OK, + reason=b'OK', body=b'Hello from man in the middle') + ) diff --git a/tests/test_http_proxy_tls_interception.py b/tests/http/test_http_proxy_tls_interception.py similarity index 100% rename from tests/test_http_proxy_tls_interception.py rename to tests/http/test_http_proxy_tls_interception.py diff --git a/tests/test_http_request_rejected.py b/tests/http/test_http_request_rejected.py similarity index 100% rename from tests/test_http_request_rejected.py rename to tests/http/test_http_request_rejected.py diff --git a/tests/test_protocol_handler.py b/tests/http/test_protocol_handler.py similarity index 100% rename from tests/test_protocol_handler.py rename to tests/http/test_protocol_handler.py diff --git a/tests/test_web_server.py b/tests/http/test_web_server.py similarity index 100% rename from tests/test_web_server.py rename to tests/http/test_web_server.py diff --git a/tests/test_websocket_client.py b/tests/http/test_websocket_client.py similarity index 100% rename from tests/test_websocket_client.py rename to tests/http/test_websocket_client.py diff --git a/tests/test_websocket_frame.py b/tests/http/test_websocket_frame.py similarity index 100% rename from tests/test_websocket_frame.py rename to tests/http/test_websocket_frame.py diff --git a/tests/http/utils.py b/tests/http/utils.py new file mode 100644 index 0000000000..ccd578af4a --- /dev/null +++ b/tests/http/utils.py @@ -0,0 +1,26 @@ +from typing import Type +from proxy.http.proxy import HttpProxyBasePlugin + +from plugin_examples import modify_post_data +from plugin_examples import mock_rest_api +from plugin_examples import redirect_to_custom_server +from plugin_examples import filter_by_upstream +from plugin_examples import cache_responses +from plugin_examples import man_in_the_middle + + +def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]: + plugin: Type[HttpProxyBasePlugin] = modify_post_data.ModifyPostDataPlugin + if test_name == 'test_modify_post_data_plugin': + plugin = modify_post_data.ModifyPostDataPlugin + elif test_name == 'test_proposed_rest_api_plugin': + plugin = mock_rest_api.ProposedRestApiPlugin + elif test_name == 'test_redirect_to_custom_server_plugin': + plugin = redirect_to_custom_server.RedirectToCustomServerPlugin + elif test_name == 'test_filter_by_upstream_host_plugin': + plugin = filter_by_upstream.FilterByUpstreamHostPlugin + elif test_name == 'test_cache_responses_plugin': + plugin = cache_responses.CacheResponsesPlugin + elif test_name == 'test_man_in_the_middle_plugin': + plugin = man_in_the_middle.ManInTheMiddlePlugin + return plugin From 183d03b8669e702613a85a2289f2755b2a671559 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 6 Nov 2019 01:33:12 -0800 Subject: [PATCH 024/107] Separate packages for Dashboard (#157) * Refactor Makefile and add dashboard setup.py * Package dashboard as proxy.py-dashboard pip package * Give dashboard releases its own version * Fix lib-package reference --- .editorconfig | 2 +- .gitignore | 1 - Makefile | 73 ++++++++++++++++++++----------------- dashboard/.gitignore | 6 +++ dashboard/MANIFEST.in | 3 ++ dashboard/README.MD | 1 + dashboard/__init__.py | 11 ++++++ dashboard/dashboard.py | 3 ++ dashboard/package.json | 6 +-- dashboard/setup.py | 83 ++++++++++++++++++++++++++++++++++++++++++ setup.py | 8 ++-- 11 files changed, 156 insertions(+), 41 deletions(-) create mode 100644 dashboard/.gitignore create mode 100644 dashboard/MANIFEST.in create mode 100644 dashboard/README.MD create mode 100644 dashboard/__init__.py create mode 100644 dashboard/setup.py diff --git a/.editorconfig b/.editorconfig index 662bdade94..8d6151163d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ insert_final_newline = true trim_trailing_whitespace = true [Makefile] -indent_size = tab +indent_size = 8 [*.py] indent_style = space diff --git a/.gitignore b/.gitignore index dbb13eff17..d84f547764 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ proxy.py.iml ca-*.pem https-*.pem -node_modules venv cover htmlcov diff --git a/Makefile b/Makefile index 0286330cfe..090775564d 100644 --- a/Makefile +++ b/Makefile @@ -13,11 +13,12 @@ CA_KEY_FILE_PATH := ca-key.pem CA_CERT_FILE_PATH := ca-cert.pem CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem -.PHONY: all clean-lib test-lib package test-release release coverage lint autopep8 -.PHONY: container run-container release-container https-certificates ca-certificates -.PHONY: profile dashboard clean-dashboard +.PHONY: all https-certificates ca-certificates autopep8 +.PHONY: lib-clean lib-test lib-package lib-release-test lib-release lib-coverage lib-lint lib-profile +.PHONY: container container-run container-release +.PHONY: dashboard dashboard-clean dashboard-package -all: clean-lib test-lib +all: lib-clean lib-test autopep8: autopep8 --recursive --in-place --aggressive proxy/*.py @@ -28,6 +29,12 @@ autopep8: autopep8 --recursive --in-place --aggressive dashboard/*.py autopep8 --recursive --in-place --aggressive setup.py +https-certificates: + # Generate server key + openssl genrsa -out $(HTTPS_KEY_FILE_PATH) 2048 + # Generate server certificate + openssl req -new -x509 -days 3650 -key $(HTTPS_KEY_FILE_PATH) -out $(HTTPS_CERT_FILE_PATH) + ca-certificates: # Generate CA key openssl genrsa -out $(CA_KEY_FILE_PATH) 2048 @@ -37,7 +44,7 @@ ca-certificates: # Generated certificates are then signed with CA certificate / key generated above openssl genrsa -out $(CA_SIGNING_KEY_FILE_PATH) 2048 -clean-lib: +lib-clean: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + @@ -49,47 +56,47 @@ clean-lib: rm -rf .pytest_cache rm -rf .hypothesis -clean-dashboard: - rm -rf public/dashboard +lib-lint: + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py -container: - docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . +lib-test: lib-lint + python -m unittest discover -coverage: +lib-package: lib-clean + python setup.py sdist bdist_wheel + +lib-release-test: lib-package + twine upload --verbose --repository-url https://test.pypi.org/legacy/ dist/* + +lib-release: lib-package + twine upload dist/* + +lib-coverage: pytest --cov=proxy --cov-report=html tests/ open htmlcov/index.html +lib-profile: + sudo py-spy -F -f profile.svg -d 3600 proxy.py + dashboard: pushd dashboard && npm run build && popd -https-certificates: - # Generate server key - openssl genrsa -out $(HTTPS_KEY_FILE_PATH) 2048 - # Generate server certificate - openssl req -new -x509 -days 3650 -key $(HTTPS_KEY_FILE_PATH) -out $(HTTPS_CERT_FILE_PATH) - -lint: - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py +dashboard-clean: + if [[ -d public/dashboard ]]; then rm -rf public/dashboard; fi -package: clean - python setup.py sdist bdist_wheel +dashboard-package-clean: + pushd dashboard && rm -rf build && rm -rf dist && popd -profile: - sudo py-spy -F -f profile.svg -d 3600 proxy.py +dashboard-package: dashboard-package-clean + pushd dashboard && npm test && PYTHONPATH=.. python setup.py sdist bdist_wheel && popd -release: package - twine upload dist/* +container: + docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . -release-container: +container-release: docker push $(IMAGE_TAG) docker push $(LATEST_TAG) -run-container: +container-run: docker run -it -p 8899:8899 --rm $(LATEST_TAG) - -test-lib: lint - python -m unittest discover - -test-release: package - twine upload --verbose --repository-url https://test.pypi.org/legacy/ dist/* diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000000..14a996aeba --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,6 @@ +tsbuild +node_modules +build +dist + +proxy.py_dashboard.egg-info diff --git a/dashboard/MANIFEST.in b/dashboard/MANIFEST.in new file mode 100644 index 0000000000..4ae453e25f --- /dev/null +++ b/dashboard/MANIFEST.in @@ -0,0 +1,3 @@ +include src/proxy.html +include src/proxy.css +include tsbuild/src/proxy.js diff --git a/dashboard/README.MD b/dashboard/README.MD new file mode 100644 index 0000000000..2ad45876ce --- /dev/null +++ b/dashboard/README.MD @@ -0,0 +1 @@ +# Proxy.py Dashboard diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100644 index 0000000000..f70d5120a3 --- /dev/null +++ b/dashboard/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .dashboard import __version__ diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index b2ea55d86f..ae0487bd11 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -24,6 +24,9 @@ from proxy.common.types import DictQueueType from proxy.core.connection import TcpClientConnection +VERSION = (0, 1, 0) +__version__ = '.'.join(map(str, VERSION[0:3])) + logger = logging.getLogger(__name__) diff --git a/dashboard/package.json b/dashboard/package.json index 57a3a5fee0..333dd34733 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -4,10 +4,10 @@ "description": "Frontend dashboard for proxy.py", "main": "index.js", "scripts": { - "clean": "rm -rf build", + "clean": "rm -rf tsbuild", "lint": "eslint --global $ src/*.ts", - "pretest": "npm run clean && npm run lint && tsc --target es5 --outDir build test/test.ts", - "test": "jasmine build/test/test.js", + "pretest": "npm run clean && npm run lint && tsc --target es5 --outDir tsbuild test/test.ts", + "test": "jasmine tsbuild/test/test.js", "build": "npm test && rollup -c", "start": "pushd ../public && http-server -g true -i false -d false -c-1 --no-dotfiles . && popd", "watch": "rollup -c -w" diff --git a/dashboard/setup.py b/dashboard/setup.py new file mode 100644 index 0000000000..f0413a63ff --- /dev/null +++ b/dashboard/setup.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from setuptools import setup, find_packages + +from proxy.common.constants import __author__, __author_email__ +from proxy.common.constants import __homepage__, __description__, __download_url__, __license__ + +from dashboard import __version__ + +setup( + name='proxy.py-dashboard', + version=__version__, + author=__author__, + author_email=__author_email__, + url=__homepage__, + description=__description__, + long_description=open('README.md').read().strip(), + long_description_content_type='text/markdown', + download_url=__download_url__, + license=__license__, + packages=find_packages(), + install_requires=['proxy.py'], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Environment :: No Input/Output (Daemon)', + 'Environment :: Web Environment', + 'Environment :: MacOS X', + 'Environment :: Plugins', + 'Environment :: Win32 (MS Windows)', + 'Framework :: Robot Framework', + 'Framework :: Robot Framework :: Library', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: MacOS', + 'Operating System :: MacOS :: MacOS 9', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Unix', + 'Operating System :: Microsoft', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft :: Windows :: Windows 10', + 'Operating System :: Android', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: Implementation', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Topic :: Internet', + 'Topic :: Internet :: Proxy Servers', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', + 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'Topic :: Software Development :: Debuggers', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Networking', + 'Topic :: System :: Networking :: Firewalls', + 'Topic :: System :: Networking :: Monitoring', + 'Topic :: Utilities', + 'Typing :: Typed', + ], +) diff --git a/setup.py b/setup.py index af69f96c84..dcbbb622bc 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. @@ -10,7 +11,8 @@ from setuptools import setup, find_packages from proxy.common.version import __version__ -from proxy.common.constants import __author__, __author_email__, __homepage__, __description__, __download_url__, __license__ +from proxy.common.constants import __author__, __author_email__ +from proxy.common.constants import __homepage__, __description__, __download_url__, __license__ setup( name='proxy.py', @@ -23,7 +25,7 @@ long_description_content_type='text/markdown', download_url=__download_url__, license=__license__, - packages=find_packages(exclude=['benchmark', 'tests', 'plugin_examples']), + packages=find_packages(exclude=['benchmark', 'dashboard', 'plugin_examples', 'tests']), install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ 'console_scripts': [ From 93d8a55c0e39a7d8065dc814e84523277e055cb9 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 7 Nov 2019 20:53:08 -0800 Subject: [PATCH 025/107] Add non-blocking embedded mode feature (#159) * Fixes #158 * mypy fixes * Instructions for non-blocking embed mode * Toggle running flag before shutdown --- README.md | 33 ++++++++++++++++++++++++++++++--- proxy/core/acceptor.py | 13 ++++++------- proxy/main.py | 18 ++++++++++++------ setup.py | 8 +++++++- 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5f60898eee..add732a624 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ Table of Contents * [End-to-End Encryption](#end-to-end-encryption) * [TLS Interception](#tls-interception) * [Embed proxy.py](#embed-proxypy) + * [Blocking Mode](#blocking-mode) + * [Non-blocking Mode](#non-blocking-mode) * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) * [Everything is a plugin](#everything-is-a-plugin) * [Internal Architecture](#internal-architecture) @@ -125,7 +127,7 @@ Features - See [End-to-End Encryption](#end-to-end-encryption) - Man-In-The-Middle - Can decrypt TLS traffic between clients and upstream servers - - See [TLS Encryption](#tls-interception) + - See [TLS Interception](#tls-interception) - Supported proxy protocols - `http` - `https` @@ -162,7 +164,7 @@ Start proxy.py ## From command line when installed using PIP When `proxy.py` is installed using `pip`, -an executable named `proxy` is added under your `$PATH`. +an executable named `proxy` is placed under your `$PATH`. #### Run it @@ -711,7 +713,9 @@ Now use CA flags with other Embed proxy.py ============== -To start `proxy.py` in embedded mode: +## Blocking Mode + +Start `proxy.py` in embedded mode by using `main` method: ``` from proxy.main import main @@ -732,6 +736,29 @@ if __name__ == '__main__': ]) ``` +Note that: + +1. Calling `main` is simply equivalent to starting `proxy.py` from command line. +2. `main` will block until `proxy.py` shuts down. + +## Non-blocking Mode + +Start `proxy.py` in embedded mode by using `start` method: + +``` +from proxy.main import start + +if __name__ == '__main__': + with start([]): + # ... your logic here ... +``` + +Note that: + +1. `start` is simply a context manager. +2. Is similar to calling `main` except `start` won't block. +3. It automatically shut down `proxy.py`. + Plugin Developer and Contributor Guide ====================================== diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor.py index c5d26a3e1b..d85ac441eb 100644 --- a/proxy/core/acceptor.py +++ b/proxy/core/acceptor.py @@ -34,7 +34,6 @@ class AcceptorPool: def __init__(self, flags: Flags, work_klass: Type[ThreadlessWork]) -> None: self.flags = flags - self.running: bool = False self.socket: Optional[socket.socket] = None self.acceptors: List[Acceptor] = [] self.work_queues: List[connection.Connection] = [] @@ -69,7 +68,7 @@ def start_workers(self) -> None: event_queue=self.event_queue ) acceptor.start() - logger.debug('Started acceptor process %d', acceptor.pid) + logger.debug('Started acceptor#%d process %d', acceptor_id, acceptor.pid) self.acceptors.append(acceptor) self.work_queues.append(work_queue[0]) logger.info('Started %d workers' % self.flags.num_workers) @@ -90,6 +89,8 @@ def start_event_dispatcher(self) -> None: def shutdown(self) -> None: logger.info('Shutting down %d workers' % self.flags.num_workers) + for acceptor in self.acceptors: + acceptor.running.set() if self.flags.enable_events: assert self.event_dispatcher_shutdown assert self.event_dispatcher_thread @@ -104,7 +105,6 @@ def shutdown(self) -> None: def setup(self) -> None: """Listen on port, setup workers and pass server socket to workers.""" - self.running = True self.listen() if self.flags.enable_events: self.start_event_dispatcher() @@ -145,7 +145,7 @@ def __init__( self.work_klass = work_klass self.event_queue = event_queue - self.running = False + self.running = multiprocessing.Event() self.selector: Optional[selectors.DefaultSelector] = None self.sock: Optional[socket.socket] = None self.threadless_process: Optional[multiprocessing.Process] = None @@ -208,7 +208,6 @@ def run_once(self) -> None: # logger.info('Work started for fd %d in %f seconds', fileno, time.time() - now) def run(self) -> None: - self.running = True self.selector = selectors.DefaultSelector() fileno = recv_handle(self.work_queue) self.work_queue.close() @@ -221,7 +220,7 @@ def run(self) -> None: self.selector.register(self.sock, selectors.EVENT_READ) if self.flags.threadless: self.start_threadless_process() - while self.running: + while not self.running.is_set(): self.run_once() except KeyboardInterrupt: pass @@ -230,4 +229,4 @@ def run(self) -> None: if self.flags.threadless: self.shutdown_threadless_process() self.sock.close() - self.running = False + logger.debug('Acceptor#%d shutdown', self.idd) diff --git a/proxy/main.py b/proxy/main.py index 081460c78b..ca223cc23f 100755 --- a/proxy/main.py +++ b/proxy/main.py @@ -8,6 +8,7 @@ :license: BSD, see LICENSE for more details. """ import base64 +import contextlib import importlib import inspect import ipaddress @@ -16,7 +17,7 @@ import os import sys import time -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Generator from .common.flags import Flags, init_parser from .common.utils import text_, bytes_ @@ -101,7 +102,8 @@ def setup_logger( logging.basicConfig(level=ll, format=log_format) -def main(input_args: List[str]) -> None: +@contextlib.contextmanager +def start(input_args: List[str]) -> Generator[None, None, None]: if not is_py3(): print( 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' @@ -112,7 +114,6 @@ def main(input_args: List[str]) -> None: 'Please upgrade your Python as Python 2.7 won\'t be maintained after that date. ' 'A future version of pip will drop support for Python 2.7.') sys.exit(1) - args = init_parser().parse_args(input_args) if args.version: @@ -194,9 +195,7 @@ def main(input_args: List[str]) -> None: try: acceptor_pool.setup() - # TODO: Introduce cron feature instead of mindless sleep - while True: - time.sleep(2**10) + yield except Exception as e: logger.exception('exception', exc_info=e) finally: @@ -208,5 +207,12 @@ def main(input_args: List[str]) -> None: os.remove(args.pid_file) +def main(input_args: List[str]) -> None: + with start(input_args): + # TODO: Introduce cron feature instead of mindless sleep + while True: + time.sleep(1) + + def entry_point() -> None: main(sys.argv[1:]) diff --git a/setup.py b/setup.py index dcbbb622bc..dd48f2cef9 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,13 @@ long_description_content_type='text/markdown', download_url=__download_url__, license=__license__, - packages=find_packages(exclude=['benchmark', 'dashboard', 'plugin_examples', 'tests']), + packages=find_packages( + exclude=[ + 'benchmark', + 'dashboard', + 'plugin_examples', + 'tests' + ]), install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ 'console_scripts': [ From 0cc4e5e625865d1f790059f8f57d9b79c98b73a8 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 11 Nov 2019 16:17:13 -0800 Subject: [PATCH 026/107] Add private / public key generation utils which comply with new requirements on Mac OS 10.15 (#160) * Add utilities to generate private key and public keys with alternate cnames * Add separate package proxy.py-plugins, fixes #156 * Generate certificates to comply with Mac requirements. * Add utility for CSR generation and signing * Fixes #161 * Add initial pki tests --- .gitignore | 9 +- Makefile | 7 + README.md | 13 +- dashboard/.gitignore | 2 - dashboard/{README.MD => README.md} | 0 dashboard/src/proxy.ts | 6 +- plugin_examples/README.md | 1 + plugin_examples/setup.py | 84 ++++++++++++ proxy/common/flags.py | 9 +- proxy/common/pki.py | 213 +++++++++++++++++++++++++++++ proxy/core/acceptor.py | 5 +- proxy/http/handler.py | 5 +- tests/common/test_pki.py | 68 +++++++++ 13 files changed, 403 insertions(+), 19 deletions(-) rename dashboard/{README.MD => README.md} (100%) create mode 100644 plugin_examples/README.md create mode 100644 plugin_examples/setup.py create mode 100644 proxy/common/pki.py create mode 100644 tests/common/test_pki.py diff --git a/.gitignore b/.gitignore index d84f547764..c9aa49f216 100644 --- a/.gitignore +++ b/.gitignore @@ -10,12 +10,15 @@ coverage.xml proxy.py.iml *.pyc -ca-*.pem -https-*.pem + +*.csr +*.crt +*.key venv cover htmlcov dist build -proxy.py.egg-info + +*.egg-info diff --git a/Makefile b/Makefile index 090775564d..a50e29b578 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem .PHONY: lib-clean lib-test lib-package lib-release-test lib-release lib-coverage lib-lint lib-profile .PHONY: container container-run container-release .PHONY: dashboard dashboard-clean dashboard-package +.PHONY: plugin-package-clean plugin-package all: lib-clean lib-test @@ -91,6 +92,12 @@ dashboard-package-clean: dashboard-package: dashboard-package-clean pushd dashboard && npm test && PYTHONPATH=.. python setup.py sdist bdist_wheel && popd +plugin-package-clean: + pushd plugin_examples && rm -rf build && rm -rf dist && popd + +plugin-package: plugin-package-clean + pushd plugin_examples && PYTHONPATH=.. python setup.py sdist bdist_wheel && popd + container: docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . diff --git a/README.md b/README.md index add732a624..2917a922e0 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Table of Contents * [Unable to connect with proxy.py from remote host](#unable-to-connect-with-proxypy-from-remote-host) * [Basic auth not working with a browser](#basic-auth-not-working-with-a-browser) * [Docker image not working on MacOS](#docker-image-not-working-on-macos) - * [Unable to load custom plugins](#unable-to-load-custom-plugins) + * [Unable to load plugins](#unable-to-load-plugins) * [ValueError: filedescriptor out of range in select](#valueerror-filedescriptor-out-of-range-in-select) * [Flags](#flags) * [Changelog](#changelog) @@ -129,8 +129,9 @@ Features - Can decrypt TLS traffic between clients and upstream servers - See [TLS Interception](#tls-interception) - Supported proxy protocols - - `http` - - `https` + - `http(s)` + - `http1` + - `http1.1` pipeline - `http2` - `websockets` - Optimized for large file uploads and downloads @@ -968,9 +969,9 @@ See [moby/vpnkit exhausts docker resources](https://github.com/abhinavsingh/prox and [Connection refused: The proxy could not connect](https://github.com/moby/vpnkit/issues/469) for some background. -## Unable to load custom plugins +## Unable to load plugins -Make sure your plugin modules are discoverable by adding them to `PYTHONPATH`. Example: +Make sure plugin modules are discoverable by adding them to `PYTHONPATH`. Example: `PYTHONPATH=/path/to/my/app proxy --plugins my_app.proxyPlugin` @@ -983,6 +984,8 @@ or, make sure to pass fully-qualified path as parameter, e.g. `proxy --plugins /path/to/my/app/my_app.proxyPlugin` +Note that `pip install proxy.py` don't ship [plugin_examples](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples). + ## GCE log viewer integration for proxy.py A starter [fluentd.conf](https://github.com/abhinavsingh/proxy.py/blob/develop/fluentd.conf) diff --git a/dashboard/.gitignore b/dashboard/.gitignore index 14a996aeba..89b66a3a9b 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -2,5 +2,3 @@ tsbuild node_modules build dist - -proxy.py_dashboard.egg-info diff --git a/dashboard/README.MD b/dashboard/README.md similarity index 100% rename from dashboard/README.MD rename to dashboard/README.md diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index b21eb0cb1e..4f3990fd99 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -43,10 +43,10 @@ class ApiDevelopment { } class WebsocketApi { - private hostname: string = 'localhost'; - private port: number = 8899; + private hostname: string = window.location.hostname ? window.location.hostname : 'localhost'; + private port: number = window.location.port ? Number(window.location.port) : 8899; private wsPrefix: string = '/dashboard'; - private wsScheme: string = 'ws'; + private wsScheme: string = window.location.protocol === 'http:' ? 'ws' : 'wss'; private ws: WebSocket; private wsPath: string = this.wsScheme + '://' + this.hostname + ':' + this.port + this.wsPrefix; diff --git a/plugin_examples/README.md b/plugin_examples/README.md new file mode 100644 index 0000000000..34f725ca17 --- /dev/null +++ b/plugin_examples/README.md @@ -0,0 +1 @@ +# Proxy.py Plugins diff --git a/plugin_examples/setup.py b/plugin_examples/setup.py new file mode 100644 index 0000000000..18899f2109 --- /dev/null +++ b/plugin_examples/setup.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from setuptools import setup, find_packages + +from proxy.common.constants import __author__, __author_email__ +from proxy.common.constants import __homepage__, __description__, __download_url__, __license__ + +VERSION = (0, 1, 0) +__version__ = '.'.join(map(str, VERSION[0:3])) + +setup( + name='proxy.py-plugins', + version=__version__, + author=__author__, + author_email=__author_email__, + url=__homepage__, + description=__description__, + long_description=open('README.md').read().strip(), + long_description_content_type='text/markdown', + download_url=__download_url__, + license=__license__, + packages=find_packages(), + install_requires=['proxy.py'], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Environment :: No Input/Output (Daemon)', + 'Environment :: Web Environment', + 'Environment :: MacOS X', + 'Environment :: Plugins', + 'Environment :: Win32 (MS Windows)', + 'Framework :: Robot Framework', + 'Framework :: Robot Framework :: Library', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: MacOS', + 'Operating System :: MacOS :: MacOS 9', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Unix', + 'Operating System :: Microsoft', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft :: Windows :: Windows 10', + 'Operating System :: Android', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: Implementation', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Topic :: Internet', + 'Topic :: Internet :: Proxy Servers', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', + 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'Topic :: Software Development :: Debuggers', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Networking', + 'Topic :: System :: Networking :: Firewalls', + 'Topic :: System :: Networking :: Monitoring', + 'Topic :: Utilities', + 'Typing :: Typed', + ], +) diff --git a/proxy/common/flags.py b/proxy/common/flags.py index a3e5b2e5c8..a49ba3915f 100644 --- a/proxy/common/flags.py +++ b/proxy/common/flags.py @@ -137,10 +137,11 @@ def init_parser() -> argparse.ArgumentParser: action='store_true', default=DEFAULT_ENABLE_WEB_SERVER, help='Default: False. Whether to enable proxy.HttpWebServerPlugin.') - parser.add_argument('--hostname', - type=str, - default=str(DEFAULT_IPV6_HOSTNAME), - help='Default: ::1. Server IP address.') + parser.add_argument( + '--hostname', + type=str, + default=str(DEFAULT_IPV6_HOSTNAME), + help='Default: ::1. Server IP address.') parser.add_argument( '--key-file', type=str, diff --git a/proxy/common/pki.py b/proxy/common/pki.py new file mode 100644 index 0000000000..40eb52681f --- /dev/null +++ b/proxy/common/pki.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import contextlib +import os +import uuid +import subprocess +import tempfile +import logging +from typing import List, Generator, Optional, Tuple + +from .utils import bytes_ +from .constants import COMMA + + +logger = logging.getLogger(__name__) + + +DEFAULT_CONFIG = b'''[ req ] +#default_bits = 2048 +#default_md = sha256 +#default_keyfile = privkey.pem +distinguished_name = req_distinguished_name +attributes = req_attributes + +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +countryName_min = 2 +countryName_max = 2 +stateOrProvinceName = State or Province Name (full name) +localityName = Locality Name (eg, city) +0.organizationName = Organization Name (eg, company) +organizationalUnitName = Organizational Unit Name (eg, section) +commonName = Common Name (eg, fully qualified host name) +commonName_max = 64 +emailAddress = Email Address +emailAddress_max = 64 + +[ req_attributes ] +challengePassword = A challenge password +challengePassword_min = 4 +challengePassword_max = 20''' + + +def remove_passphrase( + key_in_path: str, + password: str, + key_out_path: str, + timeout: int = 10) -> bool: + """Remove passphrase from a private key.""" + command = [ + 'openssl', 'rsa', + '-passin', 'pass:%s' % password, + '-in', key_in_path, + '-out', key_out_path + ] + return run_openssl_command(command, timeout) + + +def gen_private_key( + key_path: str, + password: str, + bits: int = 2048, + timeout: int = 10) -> bool: + """Generates a private key.""" + command = [ + 'openssl', 'genrsa', '-aes256', + '-passout', 'pass:%s' % password, + '-out', key_path, str(bits) + ] + return run_openssl_command(command, timeout) + + +def gen_public_key( + public_key_path: str, + private_key_path: str, + private_key_password: str, + subject: str, + alt_subj_names: Optional[List[str]] = None, + extended_key_usage: Optional[str] = None, + validity_in_days: int = 365, + timeout: int = 10) -> bool: + """For a given private key, generates a corresponding public key.""" + with ssl_config(alt_subj_names, extended_key_usage) as (config_path, has_extension): + command = [ + 'openssl', 'req', '-new', '-x509', '-sha256', + '-days', str(validity_in_days), '-subj', subject, + '-passin', 'pass:%s' % private_key_password, + '-config', config_path, + '-key', private_key_path, '-out', public_key_path + ] + if has_extension: + command.extend([ + '-extensions', 'PROXY', + ]) + return run_openssl_command(command, timeout) + + +def gen_csr( + csr_path: str, + key_path: str, + password: str, + crt_path: str, + timeout: int = 10) -> bool: + """Generates a CSR based upon existing certificate and key file.""" + command = [ + 'openssl', 'x509', '-x509toreq', + '-passin', 'pass:%s' % password, + '-in', crt_path, '-signkey', key_path, + '-out', csr_path + ] + return run_openssl_command(command, timeout) + + +def sign_csr( + csr_path: str, + crt_path: str, + ca_key_path: str, + ca_key_password: str, + ca_crt_path: str, + serial: str, + alt_subj_names: Optional[List[str]] = None, + extended_key_usage: Optional[str] = None, + validity_in_days: int = 365, + timeout: int = 10) -> bool: + """Sign a CSR using CA key and certificate.""" + with ext_file(alt_subj_names, extended_key_usage) as extension_path: + command = [ + 'openssl', 'x509', '-req', '-sha256', + '-CA', ca_crt_path, + '-CAkey', ca_key_path, + '-passin', 'pass:%s' % ca_key_password, + '-set_serial', serial, + '-days', str(validity_in_days), + '-extfile', extension_path, + '-in', csr_path, + '-out', crt_path, + ] + return run_openssl_command(command, timeout) + + +def get_ext_config( + alt_subj_names: Optional[List[str]] = None, + extended_key_usage: Optional[str] = None) -> bytes: + config = b'' + # Add SAN extension + if alt_subj_names is not None and len(alt_subj_names) > 0: + alt_names = [] + for cname in alt_subj_names: + alt_names.append(b'DNS:%s' % bytes_(cname)) + config += b'\nsubjectAltName=' + COMMA.join(alt_names) + # Add extendedKeyUsage section + if extended_key_usage is not None: + config += b'\nextendedKeyUsage=' + bytes_(extended_key_usage) + return config + + +@contextlib.contextmanager +def ext_file( + alt_subj_names: Optional[List[str]] = None, + extended_key_usage: Optional[str] = None) -> Generator[str, None, None]: + # Write config to temp file + config_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex) + with open(config_path, 'wb') as cnf: + cnf.write( + get_ext_config(alt_subj_names, extended_key_usage)) + + yield config_path + + # Delete temp file + os.remove(config_path) + + +@contextlib.contextmanager +def ssl_config( + alt_subj_names: Optional[List[str]] = None, + extended_key_usage: Optional[str] = None) -> Generator[Tuple[str, bool], None, None]: + config = DEFAULT_CONFIG + + has_extension = False + if (alt_subj_names is not None and len(alt_subj_names) > 0) or \ + extended_key_usage is not None: + has_extension = True + config += b'\n[PROXY]' + + # Add custom extensions + config += get_ext_config(alt_subj_names, extended_key_usage) + + # Write config to temp file + config_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex) + with open(config_path, 'wb') as cnf: + cnf.write(config) + + yield config_path, has_extension + + # Delete temp file + os.remove(config_path) + + +def run_openssl_command(command: List[str], timeout: int) -> bool: + cmd = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + cmd.communicate(timeout=timeout) + return cmd.returncode == 0 diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor.py index d85ac441eb..44eed41bd3 100644 --- a/proxy/core/acceptor.py +++ b/proxy/core/acceptor.py @@ -68,7 +68,10 @@ def start_workers(self) -> None: event_queue=self.event_queue ) acceptor.start() - logger.debug('Started acceptor#%d process %d', acceptor_id, acceptor.pid) + logger.debug( + 'Started acceptor#%d process %d', + acceptor_id, + acceptor.pid) self.acceptors.append(acceptor) self.work_queues.append(work_queue[0]) logger.info('Started %d workers' % self.flags.num_workers) diff --git a/proxy/http/handler.py b/proxy/http/handler.py index b954893987..d14165c5ef 100644 --- a/proxy/http/handler.py +++ b/proxy/http/handler.py @@ -249,7 +249,10 @@ def optionally_wrap_socket( ctx.load_cert_chain( certfile=self.flags.certfile, keyfile=self.flags.keyfile) - conn = ctx.wrap_socket(conn, server_side=True) + conn = ctx.wrap_socket( + conn, + server_side=True, + ) return conn def connection_inactive_for(self) -> float: diff --git a/tests/common/test_pki.py b/tests/common/test_pki.py new file mode 100644 index 0000000000..7a2b7d33f5 --- /dev/null +++ b/tests/common/test_pki.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import subprocess +from unittest import mock + +from proxy.common import pki + + +class TestPki(unittest.TestCase): + + @mock.patch('subprocess.Popen') + def test_run_openssl_command(self, mock_popen: mock.Mock) -> None: + command = ['my', 'custom', 'command'] + mock_popen.return_value.returncode = 0 + self.assertTrue(pki.run_openssl_command(command, 10)) + mock_popen.assert_called_with(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + def test_get_ext_config(self) -> None: + self.assertEqual(pki.get_ext_config(None, None), b'') + self.assertEqual(pki.get_ext_config([], None), b'') + self.assertEqual(pki.get_ext_config(['proxy.py'], None), b'\nsubjectAltName=DNS:proxy.py') + self.assertEqual(pki.get_ext_config(None, 'serverAuth'), b'\nextendedKeyUsage=serverAuth') + self.assertEqual(pki.get_ext_config(['proxy.py'], 'serverAuth'), + b'\nsubjectAltName=DNS:proxy.py\nextendedKeyUsage=serverAuth') + self.assertEqual(pki.get_ext_config(['proxy.py', 'www.proxy.py'], 'serverAuth'), + b'\nsubjectAltName=DNS:proxy.py,DNS:www.proxy.py\nextendedKeyUsage=serverAuth') + + def test_ssl_config_no_ext(self) -> None: + with pki.ssl_config() as (config_path, has_extension): + self.assertFalse(has_extension) + with open(config_path, 'rb') as config: + self.assertEqual(config.read(), pki.DEFAULT_CONFIG) + + def test_ssl_config(self) -> None: + with pki.ssl_config(['proxy.py']) as (config_path, has_extension): + self.assertTrue(has_extension) + with open(config_path, 'rb') as config: + self.assertEqual(config.read(), pki.DEFAULT_CONFIG + b'\n[PROXY]\nsubjectAltName=DNS:proxy.py') + + def test_extfile_no_ext(self) -> None: + with pki.ext_file() as config_path: + with open(config_path, 'rb') as config: + self.assertEqual(config.read(), b'') + + def test_extfile(self) -> None: + with pki.ext_file(['proxy.py']) as config_path: + with open(config_path, 'rb') as config: + self.assertEqual(config.read(), b'\nsubjectAltName=DNS:proxy.py') + + def test_gen_private_key(self) -> None: + pass + + def test_gen_public_key(self) -> None: + pass + + def test_gen_csr(self) -> None: + pass + + def test_sign_csr(self) -> None: + pass From ee7a69b1fc08aafbb0b73677ed054ca00a42be1e Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 00:02:15 -0800 Subject: [PATCH 027/107] Give structure to dashboard app (#163) * Separate out files for different responsibilities. 1. Add src/plugins directory. This directory holds one typescript file per plugin. Each plugin is optionally can be displayed as a tab on the UI. 2. Move WebsocketApi to ws.ts. This file contains all websocket APIs provided by dashboard.py backend. * Make dashboard pluggable * Move devtools under core too * Register tabs dynamically * Typescript fixes for abstract interfaces * Initialize plugin app body skeleton * Call activated / deactivated on tab change * Move plugin name within plugin classes and initialize plugin within proxy dashboard constructor * templatize api development plugin * eslint fixes * use globs * Remove useless constructors --- dashboard/.eslintrc.json | 10 +- dashboard/package.json | 2 +- dashboard/src/{ => core}/devtools.ts | 0 dashboard/src/core/plugin.ts | 52 ++++ dashboard/src/core/plugins/home.ts | 26 ++ dashboard/src/core/plugins/inspect_traffic.ts | 30 +++ dashboard/src/core/plugins/settings.ts | 26 ++ dashboard/src/core/plugins/traffic_control.ts | 26 ++ dashboard/src/core/ws.ts | 141 +++++++++++ dashboard/src/plugins/mock_rest_api.ts | 178 ++++++++++++++ dashboard/src/plugins/shortlink.ts | 26 ++ dashboard/src/proxy.css | 4 +- dashboard/src/proxy.html | 100 +------- dashboard/src/proxy.ts | 231 ++++-------------- dashboard/test/test.ts | 13 +- 15 files changed, 577 insertions(+), 288 deletions(-) rename dashboard/src/{ => core}/devtools.ts (100%) create mode 100644 dashboard/src/core/plugin.ts create mode 100644 dashboard/src/core/plugins/home.ts create mode 100644 dashboard/src/core/plugins/inspect_traffic.ts create mode 100644 dashboard/src/core/plugins/settings.ts create mode 100644 dashboard/src/core/plugins/traffic_control.ts create mode 100644 dashboard/src/core/ws.ts create mode 100644 dashboard/src/plugins/mock_rest_api.ts create mode 100644 dashboard/src/plugins/shortlink.ts diff --git a/dashboard/.eslintrc.json b/dashboard/.eslintrc.json index 1ad2585834..03b8e6bc36 100644 --- a/dashboard/.eslintrc.json +++ b/dashboard/.eslintrc.json @@ -19,5 +19,13 @@ "@typescript-eslint" ], "rules": { - } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "rules": { + "@typescript-eslint/no-unused-vars": [2, { "args": "none" }] + } + } + ] } diff --git a/dashboard/package.json b/dashboard/package.json index 333dd34733..a744b5c460 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "clean": "rm -rf tsbuild", - "lint": "eslint --global $ src/*.ts", + "lint": "eslint --global $ \"src/**/*.ts\" \"test/**/*.ts\"", "pretest": "npm run clean && npm run lint && tsc --target es5 --outDir tsbuild test/test.ts", "test": "jasmine tsbuild/test/test.js", "build": "npm test && rollup -c", diff --git a/dashboard/src/devtools.ts b/dashboard/src/core/devtools.ts similarity index 100% rename from dashboard/src/devtools.ts rename to dashboard/src/core/devtools.ts diff --git a/dashboard/src/core/plugin.ts b/dashboard/src/core/plugin.ts new file mode 100644 index 0000000000..bb146ad4fc --- /dev/null +++ b/dashboard/src/core/plugin.ts @@ -0,0 +1,52 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { WebsocketApi } from './ws' + +export interface IDashboardPlugin { + name: string + initializeTab(): JQuery + initializeSkeleton(): JQuery + activated(): void + deactivated(): void +} + +export interface IPluginConstructor { + new (websocketApi: WebsocketApi): IDashboardPlugin +} + +export abstract class DashboardPlugin implements IDashboardPlugin { + public abstract readonly name: string + protected websocketApi: WebsocketApi + + public constructor (websocketApi: WebsocketApi) { + this.websocketApi = websocketApi + } + + public makeTab (name: string, icon: string) : JQuery { + return $('') + .attr({ + href: '#', + plugin_name: this.name + }) + .addClass('nav-link') + .text(name) + .prepend( + $('') + .addClass('fa') + .addClass('fa-fw') + .addClass(icon) + ) + } + + public abstract initializeTab() : JQuery + public abstract initializeSkeleton(): JQuery + public abstract activated(): void + public abstract deactivated(): void +} diff --git a/dashboard/src/core/plugins/home.ts b/dashboard/src/core/plugins/home.ts new file mode 100644 index 0000000000..0ba6cf6133 --- /dev/null +++ b/dashboard/src/core/plugins/home.ts @@ -0,0 +1,26 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { DashboardPlugin } from '../plugin' + +export class HomePlugin extends DashboardPlugin { + public name: string = 'home' + + public initializeTab () : JQuery { + return this.makeTab('Home', 'fa-home') + } + + public initializeSkeleton (): JQuery { + return $('
') + } + + public activated (): void {} + + public deactivated (): void {} +} diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts new file mode 100644 index 0000000000..ba030da781 --- /dev/null +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -0,0 +1,30 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { DashboardPlugin } from '../plugin' + +export class InspectTrafficPlugin extends DashboardPlugin { + public name: string = 'inspect_traffic' + + public initializeTab () : JQuery { + return this.makeTab('Inspect Traffic', 'fa-binoculars') + } + + public initializeSkeleton (): JQuery { + return $('
') + } + + public activated (): void { + this.websocketApi.enableInspection() + } + + public deactivated (): void { + this.websocketApi.disableInspection() + } +} diff --git a/dashboard/src/core/plugins/settings.ts b/dashboard/src/core/plugins/settings.ts new file mode 100644 index 0000000000..7cbb92d427 --- /dev/null +++ b/dashboard/src/core/plugins/settings.ts @@ -0,0 +1,26 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { DashboardPlugin } from '../plugin' + +export class SettingsPlugin extends DashboardPlugin { + public name: string = 'settings' + + public initializeTab () : JQuery { + return this.makeTab('Settings', 'fa-clog') + } + + public initializeSkeleton (): JQuery { + return $('
') + } + + public activated (): void {} + + public deactivated (): void {} +} diff --git a/dashboard/src/core/plugins/traffic_control.ts b/dashboard/src/core/plugins/traffic_control.ts new file mode 100644 index 0000000000..552b9067cd --- /dev/null +++ b/dashboard/src/core/plugins/traffic_control.ts @@ -0,0 +1,26 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { DashboardPlugin } from '../plugin' + +export class TrafficControlPlugin extends DashboardPlugin { + public name: string = 'traffic_control' + + public initializeTab () : JQuery { + return this.makeTab('Traffic Controls', 'fa-lock') + } + + public initializeSkeleton (): JQuery { + return $('
') + } + + public activated (): void {} + + public deactivated (): void {} +} diff --git a/dashboard/src/core/ws.ts b/dashboard/src/core/ws.ts new file mode 100644 index 0000000000..674b183886 --- /dev/null +++ b/dashboard/src/core/ws.ts @@ -0,0 +1,141 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ + +export class WebsocketApi { + private hostname: string = window.location.hostname ? window.location.hostname : 'localhost'; + private port: number = window.location.port ? Number(window.location.port) : 8899; + // TODO: Must map to route registered by dashboard.py, don't hardcode + private wsPrefix: string = '/dashboard'; + private wsScheme: string = window.location.protocol === 'http:' ? 'ws' : 'wss'; + private ws: WebSocket; + private wsPath: string = this.wsScheme + '://' + this.hostname + ':' + this.port + this.wsPrefix; + + private mid: number = 0; + private lastPingId: number; + private lastPingTime: number; + + private readonly schedulePingEveryMs: number = 1000; + private readonly scheduleReconnectEveryMs: number = 5000; + + private serverPingTimer: number; + private serverConnectTimer: number; + + private inspectionEnabled: boolean; + + constructor () { + this.scheduleServerConnect(0) + } + + public static getTime () { + const date = new Date() + return date.getTime() + } + + public enableInspection () { + // TODO: Set flag to true only once response has been received from the server + this.inspectionEnabled = true + this.ws.send(JSON.stringify({ id: this.mid, method: 'enable_inspection' })) + this.mid++ + } + + public disableInspection () { + this.inspectionEnabled = false + this.ws.send(JSON.stringify({ id: this.mid, method: 'disable_inspection' })) + this.mid++ + } + + private scheduleServerConnect (after_ms: number = this.scheduleReconnectEveryMs) { + this.clearServerConnectTimer() + this.serverConnectTimer = window.setTimeout( + this.connectToServer.bind(this), after_ms) + } + + private connectToServer () { + this.ws = new WebSocket(this.wsPath) + this.ws.onopen = this.onServerWSOpen.bind(this) + this.ws.onmessage = this.onServerWSMessage.bind(this) + this.ws.onerror = this.onServerWSError.bind(this) + this.ws.onclose = this.onServerWSClose.bind(this) + } + + private clearServerConnectTimer () { + if (this.serverConnectTimer == null) { + return + } + window.clearTimeout(this.serverConnectTimer) + this.serverConnectTimer = null + } + + private scheduleServerPing (after_ms: number = this.schedulePingEveryMs) { + this.clearServerPingTimer() + this.serverPingTimer = window.setTimeout( + this.pingServer.bind(this), after_ms) + } + + private pingServer () { + this.lastPingId = this.mid + this.lastPingTime = WebsocketApi.getTime() + this.mid++ + // console.log('Pinging server with id:%d', this.last_ping_id); + this.ws.send(JSON.stringify({ id: this.lastPingId, method: 'ping' })) + } + + private clearServerPingTimer () { + if (this.serverPingTimer != null) { + window.clearTimeout(this.serverPingTimer) + this.serverPingTimer = null + } + this.lastPingTime = null + this.lastPingId = null + } + + private onServerWSOpen (ev: MessageEvent) { + this.clearServerConnectTimer() + WebsocketApi.setServerStatusSuccess('Connected...') + this.scheduleServerPing(0) + } + + private onServerWSMessage (ev: MessageEvent) { + const message = JSON.parse(ev.data) + if (message.id === this.lastPingId) { + WebsocketApi.setServerStatusSuccess( + String((WebsocketApi.getTime() - this.lastPingTime) + ' ms')) + this.clearServerPingTimer() + this.scheduleServerPing() + } else { + console.log(message) + } + } + + private onServerWSError (ev: MessageEvent) { + WebsocketApi.setServerStatusDanger() + } + + private onServerWSClose (ev: MessageEvent) { + this.clearServerPingTimer() + this.scheduleServerConnect() + WebsocketApi.setServerStatusDanger() + } + + public static setServerStatusDanger () { + $('#proxyServerStatus').parent('div') + .removeClass('text-success') + .addClass('text-danger') + $('#proxyServerStatusSummary').text('') + } + + public static setServerStatusSuccess (summary: string) { + $('#proxyServerStatus').parent('div') + .removeClass('text-danger') + .addClass('text-success') + $('#proxyServerStatusSummary').text( + '(' + summary + ')') + } +} diff --git a/dashboard/src/plugins/mock_rest_api.ts b/dashboard/src/plugins/mock_rest_api.ts new file mode 100644 index 0000000000..cf2b43b18f --- /dev/null +++ b/dashboard/src/plugins/mock_rest_api.ts @@ -0,0 +1,178 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { DashboardPlugin } from '../core/plugin' +import { WebsocketApi } from '../core/ws' + +export class MockRestApiPlugin extends DashboardPlugin { + public name: string = 'api_development'; + + private specs: Map>; + + constructor (websocketApi: WebsocketApi) { + super(websocketApi) + this.specs = new Map() + this.fetchExistingSpecs() + } + + public initializeTab () : JQuery { + return this.makeTab('API Development', 'fa-connectdevelop') + } + + public initializeSkeleton (): JQuery { + return $('
') + .attr('id', 'app-header') + .append( + $('
') + .addClass('container-fluid') + .append( + $('
') + .addClass('row') + .append( + $('
') + .addClass('col-6') + .append( + $('

') + .addClass('h3') + .text('API Development') + ) + ) + .append( + $('
') + .addClass('col-6') + .addClass('text-right') + .append( + $('') + .attr('type', 'button') + .addClass('btn') + .addClass('btn-primary') + .text('Create New API') + .prepend( + $('') + .addClass('fa') + .addClass('fa-fw') + .addClass('fa-plus-circle') + ) + ) + ) + ) + ) + .add( + $('
') + .attr('id', 'app-body') + .append( + $('
') + .addClass('list-group') + .addClass('position-relative') + .append( + $('
') + .attr('href', '#') + .addClass('list-group-item default text-decoration-none bg-light') + .attr('data-toggle', 'collapse') + .attr('data-target', '#api-example-com-path-specs') + .attr('data-parent', '#proxyDashboard') + .text('api.example.com') + .append( + $('') + .addClass('badge badge-info') + .text('3 Resources') + ) + ) + .append( + $('') + .addClass('position-absolute fa fa-close ml-auto btn btn-danger remove-api-spec') + .attr('title', 'Delete api.example.com') + ) + .append( + $('
') + .addClass('collapse api-path-spec') + .attr('id', 'api-example-com-path-specs') + .append( + $('
') + .addClass('list-group-item bg-light') + .text('/v1/users/') + ) + .append( + $('
') + .addClass('list-group-item bg-light') + .text('/v1/groups/') + ) + .append( + $('
') + .addClass('list-group-item bg-light') + .text('/v1/messages/') + ) + ) + ) + .append( + $('
') + .addClass('list-group') + .addClass('position-relative') + .append( + $('') + .attr('href', '#') + .addClass('list-group-item default text-decoration-none bg-light') + .attr('data-toggle', 'collapse') + .attr('data-target', '#my-api') + .attr('data-parent', '#proxyDashboard') + .text('my.api') + .append( + $('') + .addClass('badge badge-info') + .text('1 Resource') + ) + ) + .append( + $('') + .addClass('position-absolute fa fa-close ml-auto btn btn-danger remove-api-spec') + .attr('title', 'Delete my.api') + ) + .append( + $('
') + .addClass('collapse api-path-spec') + .attr('id', 'my-api') + .append( + $('
') + .addClass('list-group-item bg-light') + .text('/api/') + ) + ) + ) + ) + } + + public activated (): void {} + + public deactivated (): void {} + + private fetchExistingSpecs () { + // TODO: Fetch list of currently configured APIs from the backend + const apiExampleOrgSpec = new Map() + apiExampleOrgSpec.set('/v1/users/', { + count: 2, + next: null, + previous: null, + results: [ + { + email: 'you@example.com', + groups: [], + url: 'api.example.org/v1/users/1/', + username: 'admin' + }, + { + email: 'someone@example.com', + groups: [], + url: 'api.example.org/v1/users/2/', + username: 'someone' + } + ] + }) + this.specs.set('api.example.org', apiExampleOrgSpec) + } +} diff --git a/dashboard/src/plugins/shortlink.ts b/dashboard/src/plugins/shortlink.ts new file mode 100644 index 0000000000..ab01d33fdd --- /dev/null +++ b/dashboard/src/plugins/shortlink.ts @@ -0,0 +1,26 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +import { DashboardPlugin } from '../core/plugin' + +export class ShortlinkPlugin extends DashboardPlugin { + public name: string = 'shortlink'; + + public initializeTab () : JQuery { + return this.makeTab('Short Links', 'fa-bolt') + } + + public initializeSkeleton (): JQuery { + return $('
') + } + + public activated (): void {} + + public deactivated (): void {} +} diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css index af99872d24..955c1144e3 100644 --- a/dashboard/src/proxy.css +++ b/dashboard/src/proxy.css @@ -7,11 +7,11 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. */ -#app { +#proxyDashboard { background-color: #eeeeee; height: 100%; } -#app .remove-api-spec { +#proxyDashboard .remove-api-spec { top: 10px; right: 15px; } diff --git a/dashboard/src/proxy.html b/dashboard/src/proxy.html index d7338c5e4a..9142b3f63c 100644 --- a/dashboard/src/proxy.html +++ b/dashboard/src/proxy.html @@ -27,105 +27,11 @@
-
    -
  • - - - Home - -
  • -
  • - - - API Development - -
  • -
  • - - - Inspect Traffic - -
  • -
  • - - - Short Links - -
  • -
  • - - - Traffic Controls - -
  • -
  • - - - Settings - -
  • -
+
    -
    -
    -
    -
    -
    -
    -
    -

    API Development

    -
    -
    - -
    -
    -
    -
    -
    -
    - - api.example.com 3 Resources - - -
    -
    - /v1/users/ -
    -
    - /v1/groups/ -
    -
    - /v1/messages/ -
    -
    -
    -
    - - my.api 1 Resource - - -
    -
    - /api/ -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    +
    @@ -149,7 +55,7 @@ diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index 4f3990fd99..768f94a6eb 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -8,211 +8,80 @@ :license: BSD, see LICENSE for more details. */ -class ApiDevelopment { - private specs: Map>; +import { WebsocketApi } from './core/ws' +import { IDashboardPlugin, IPluginConstructor } from './core/plugin' - constructor () { - this.specs = new Map() - this.fetchExistingSpecs() - } - - private fetchExistingSpecs () { - // TODO: Fetch list of currently configured APIs from the backend - const apiExampleOrgSpec = new Map() - apiExampleOrgSpec.set('/v1/users/', { - count: 2, - next: null, - previous: null, - results: [ - { - email: 'you@example.com', - groups: [], - url: 'api.example.org/v1/users/1/', - username: 'admin' - }, - { - email: 'someone@example.com', - groups: [], - url: 'api.example.org/v1/users/2/', - username: 'someone' - } - ] - }) - this.specs.set('api.example.org', apiExampleOrgSpec) - } -} - -class WebsocketApi { - private hostname: string = window.location.hostname ? window.location.hostname : 'localhost'; - private port: number = window.location.port ? Number(window.location.port) : 8899; - private wsPrefix: string = '/dashboard'; - private wsScheme: string = window.location.protocol === 'http:' ? 'ws' : 'wss'; - private ws: WebSocket; - private wsPath: string = this.wsScheme + '://' + this.hostname + ':' + this.port + this.wsPrefix; +import { HomePlugin } from './core/plugins/home' +import { InspectTrafficPlugin } from './core/plugins/inspect_traffic' +import { TrafficControlPlugin } from './core/plugins/traffic_control' +import { SettingsPlugin } from './core/plugins/settings' - private mid: number = 0; - private lastPingId: number; - private lastPingTime: number; +import { MockRestApiPlugin } from './plugins/mock_rest_api' +import { ShortlinkPlugin } from './plugins/shortlink' - private readonly schedulePingEveryMs: number = 1000; - private readonly scheduleReconnectEveryMs: number = 5000; - - private serverPingTimer: number; - private serverConnectTimer: number; +export class ProxyDashboard { + private static plugins: IPluginConstructor[] = []; + private plugins: Map = new Map(); - private inspectionEnabled: boolean; + private websocketApi: WebsocketApi constructor () { - this.scheduleServerConnect(0) - } - - public enableInspection () { - // TODO: Set flag to true only once response has been received from the server - this.inspectionEnabled = true - this.ws.send(JSON.stringify({ id: this.mid, method: 'enable_inspection' })) - this.mid++ - } - - public disableInspection () { - this.inspectionEnabled = false - this.ws.send(JSON.stringify({ id: this.mid, method: 'disable_inspection' })) - this.mid++ - } - - private scheduleServerConnect (after_ms: number = this.scheduleReconnectEveryMs) { - this.clearServerConnectTimer() - this.serverConnectTimer = window.setTimeout( - this.connectToServer.bind(this), after_ms) - } - - private connectToServer () { - this.ws = new WebSocket(this.wsPath) - this.ws.onopen = this.onServerWSOpen.bind(this) - this.ws.onmessage = this.onServerWSMessage.bind(this) - this.ws.onerror = this.onServerWSError.bind(this) - this.ws.onclose = this.onServerWSClose.bind(this) - } - - private clearServerConnectTimer () { - if (this.serverConnectTimer == null) { - return - } - window.clearTimeout(this.serverConnectTimer) - this.serverConnectTimer = null - } - - private scheduleServerPing (after_ms: number = this.schedulePingEveryMs) { - this.clearServerPingTimer() - this.serverPingTimer = window.setTimeout( - this.pingServer.bind(this), after_ms) - } - - private pingServer () { - this.lastPingId = this.mid - this.lastPingTime = ProxyDashboard.getTime() - this.mid++ - // console.log('Pinging server with id:%d', this.last_ping_id); - this.ws.send(JSON.stringify({ id: this.lastPingId, method: 'ping' })) - } - - private clearServerPingTimer () { - if (this.serverPingTimer != null) { - window.clearTimeout(this.serverPingTimer) - this.serverPingTimer = null - } - this.lastPingTime = null - this.lastPingId = null - } - - private onServerWSOpen (ev: MessageEvent) { - this.clearServerConnectTimer() - ProxyDashboard.setServerStatusSuccess('Connected...') - this.scheduleServerPing(0) - } + this.websocketApi = new WebsocketApi() - private onServerWSMessage (ev: MessageEvent) { - const message = JSON.parse(ev.data) - if (message.id === this.lastPingId) { - ProxyDashboard.setServerStatusSuccess( - String((ProxyDashboard.getTime() - this.lastPingTime) + ' ms')) - this.clearServerPingTimer() - this.scheduleServerPing() - } else { - console.log(message) + for (const Plugin of ProxyDashboard.plugins) { + const p = new Plugin(this.websocketApi) + $('#proxyTopNav ul').append( + $('
  • ') + .addClass('nav-item') + .append(p.initializeTab()) + ) + $('#proxyDashboard').append( + $('
    ') + .attr('id', p.name) + .addClass('proxy-data') + .append(p.initializeSkeleton()) + ) + this.plugins.set(p.name, p) } - } - private onServerWSError (ev: MessageEvent) { - ProxyDashboard.setServerStatusDanger() - } - - private onServerWSClose (ev: MessageEvent) { - this.clearServerPingTimer() - this.scheduleServerConnect() - ProxyDashboard.setServerStatusDanger() - } -} - -export class ProxyDashboard { - private websocketApi: WebsocketApi - private apiDevelopment: ApiDevelopment - - constructor () { - this.websocketApi = new WebsocketApi() const that = this $('#proxyTopNav>ul>li>a').on('click', function () { that.switchTab(this) }) - this.apiDevelopment = new ApiDevelopment() - } - - public static getTime () { - const date = new Date() - return date.getTime() - } - - public static setServerStatusDanger () { - $('#proxyServerStatus').parent('div') - .removeClass('text-success') - .addClass('text-danger') - $('#proxyServerStatusSummary').text('') } - public static setServerStatusSuccess (summary: string) { - $('#proxyServerStatus').parent('div') - .removeClass('text-danger') - .addClass('text-success') - $('#proxyServerStatusSummary').text( - '(' + summary + ')') + public static addPlugin (Plugin: IPluginConstructor) { + ProxyDashboard.plugins.push(Plugin) } private switchTab (element: HTMLElement) { const activeLi = $('#proxyTopNav>ul>li.active') - const activeTabId = activeLi.children('a').attr('id') - const clickedTabId = $(element).attr('id') - const clickedTabContentId = $(element).text().trim().toLowerCase().replace(' ', '-') + const activeTabPluginName = activeLi.children('a').attr('plugin_name') + const clickedTabPluginName = $(element).attr('plugin_name') activeLi.removeClass('active') $(element.parentNode).addClass('active') - console.log('Clicked id %s, showing %s', clickedTabId, clickedTabContentId) - - $('#app>div.proxy-data').hide() - $('#' + clickedTabContentId).show() - - // TODO: Tab ids shouldn't be hardcoded. - // Templatize proxy.html and refer to tab_id via enum or constants - // - // 1. Enable inspection if user moved to inspect tab - // 2. Disable inspection if user moved away from inspect tab - // 3. Do nothing if activeTabId == clickedTabId - if (clickedTabId !== activeTabId) { - if (clickedTabId === 'proxyInspect') { - this.websocketApi.enableInspection() - } else if (activeTabId === 'proxyInspect') { - this.websocketApi.disableInspection() - } + console.log('Showing plugin content', clickedTabPluginName) + + if (clickedTabPluginName === activeTabPluginName) { + return + } + + $('#proxyDashboard>div.proxy-data').hide() + $('#' + clickedTabPluginName).show() + + if (activeTabPluginName !== undefined) { + this.plugins.get(activeTabPluginName).deactivated() } + this.plugins.get(clickedTabPluginName).activated() } } +ProxyDashboard.addPlugin(HomePlugin) +ProxyDashboard.addPlugin(MockRestApiPlugin) +ProxyDashboard.addPlugin(InspectTrafficPlugin) +ProxyDashboard.addPlugin(ShortlinkPlugin) +ProxyDashboard.addPlugin(TrafficControlPlugin) +ProxyDashboard.addPlugin(SettingsPlugin); + (window as any).ProxyDashboard = ProxyDashboard diff --git a/dashboard/test/test.ts b/dashboard/test/test.ts index b7b3369c0c..69651898ff 100644 --- a/dashboard/test/test.ts +++ b/dashboard/test/test.ts @@ -7,11 +7,12 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. */ +/* global describe, it, expect */ -import { ProxyDashboard } from "../src/proxy"; +import { ProxyDashboard } from '../src/proxy' -describe("test suite", () => { - it("initializes", () => { - expect(new ProxyDashboard()).toBeTruthy(); - }); -}); +describe('test suite', () => { + it('initializes', () => { + expect(new ProxyDashboard()).toBeTruthy() + }) +}) From d20cf1c764357d6745e4255e558605eb31b41f97 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 00:35:35 -0800 Subject: [PATCH 028/107] Move traffic_control outside of core plugin, it maps to several plugin examples like redirectToUpstreamHost, filterByUpstreamHost plugins (#165) --- dashboard/src/{core => }/plugins/traffic_control.ts | 2 +- dashboard/src/proxy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename dashboard/src/{core => }/plugins/traffic_control.ts (93%) diff --git a/dashboard/src/core/plugins/traffic_control.ts b/dashboard/src/plugins/traffic_control.ts similarity index 93% rename from dashboard/src/core/plugins/traffic_control.ts rename to dashboard/src/plugins/traffic_control.ts index 552b9067cd..fba6769b8a 100644 --- a/dashboard/src/core/plugins/traffic_control.ts +++ b/dashboard/src/plugins/traffic_control.ts @@ -7,7 +7,7 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. */ -import { DashboardPlugin } from '../plugin' +import { DashboardPlugin } from '../core/plugin' export class TrafficControlPlugin extends DashboardPlugin { public name: string = 'traffic_control' diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index 768f94a6eb..c48e55b641 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -13,11 +13,11 @@ import { IDashboardPlugin, IPluginConstructor } from './core/plugin' import { HomePlugin } from './core/plugins/home' import { InspectTrafficPlugin } from './core/plugins/inspect_traffic' -import { TrafficControlPlugin } from './core/plugins/traffic_control' import { SettingsPlugin } from './core/plugins/settings' import { MockRestApiPlugin } from './plugins/mock_rest_api' import { ShortlinkPlugin } from './plugins/shortlink' +import { TrafficControlPlugin } from './plugins/traffic_control' export class ProxyDashboard { private static plugins: IPluginConstructor[] = []; From 7ca7c2d8ccb3a985c215f5e0b6c4ec739b818790 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 01:59:26 -0800 Subject: [PATCH 029/107] Introduce sendMessage websocket api which allows for callbacks (#166) * Introduce sendMessage websocket api which allows for callbacks, deprecate lastPingId in favor of callbacks * Let InspectTrafficPlugin handle all pushed inspection events --- dashboard/dashboard.py | 12 ++-- dashboard/src/core/plugins/inspect_traffic.ts | 6 +- dashboard/src/core/ws.ts | 55 ++++++++++++------- dashboard/src/proxy.ts | 2 +- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index ae0487bd11..a0ac7ecacc 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -14,7 +14,7 @@ import threading import multiprocessing import uuid -from typing import List, Tuple, Optional, Any +from typing import List, Tuple, Optional, Any, Dict from proxy.http.server import HttpWebServerPlugin, HttpWebServerBasePlugin, httpProtocolTypes from proxy.http.parser import HttpParser @@ -86,7 +86,7 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: return if message['method'] == 'ping': - self.reply_pong(message['id']) + self.reply({'id': message['id'], 'response': 'pong'}) elif message['method'] == 'enable_inspection': # inspection can only be enabled if --enable-events is used if not self.flags.enable_events: @@ -112,10 +112,13 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: self.relay_sub_id = uuid.uuid4().hex self.event_queue.subscribe( self.relay_sub_id, self.relay_channel) + + self.reply({'id': message['id'], 'response': 'inspection_enabled'}) elif message['method'] == 'disable_inspection': if self.inspection_enabled: self.shutdown_relay() self.inspection_enabled = False + self.reply({'id': message['id'], 'response': 'inspection_disabled'}) else: logger.info(frame.data) logger.info(frame.opcode) @@ -140,11 +143,11 @@ def on_websocket_close(self) -> None: if self.inspection_enabled: self.shutdown_relay() - def reply_pong(self, idd: int) -> None: + def reply(self, data: Dict[str, Any]) -> None: self.client.queue( WebsocketFrame.text( bytes_( - json.dumps({'id': idd, 'response': 'pong'})))) + json.dumps(data)))) @staticmethod def relay_events( @@ -154,6 +157,7 @@ def relay_events( while not shutdown.is_set(): try: ev = channel.get(timeout=1) + ev['push'] = 'inspect_traffic' client.queue( WebsocketFrame.text( bytes_( diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index ba030da781..06574bc11b 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -21,10 +21,14 @@ export class InspectTrafficPlugin extends DashboardPlugin { } public activated (): void { - this.websocketApi.enableInspection() + this.websocketApi.enableInspection(this.handleEvents.bind(this)) } public deactivated (): void { this.websocketApi.disableInspection() } + + public handleEvents (message: Record): void { + console.log(message) + } } diff --git a/dashboard/src/core/ws.ts b/dashboard/src/core/ws.ts index 674b183886..82e67d87c8 100644 --- a/dashboard/src/core/ws.ts +++ b/dashboard/src/core/ws.ts @@ -8,6 +8,8 @@ :license: BSD, see LICENSE for more details. */ +type MessageHandler = (message: Record) => void + export class WebsocketApi { private hostname: string = window.location.hostname ? window.location.hostname : 'localhost'; private port: number = window.location.port ? Number(window.location.port) : 8899; @@ -18,16 +20,17 @@ export class WebsocketApi { private wsPath: string = this.wsScheme + '://' + this.hostname + ':' + this.port + this.wsPrefix; private mid: number = 0; - private lastPingId: number; private lastPingTime: number; private readonly schedulePingEveryMs: number = 1000; private readonly scheduleReconnectEveryMs: number = 5000; - private serverPingTimer: number; - private serverConnectTimer: number; + private serverPingTimer: number = null; + private serverConnectTimer: number = null; - private inspectionEnabled: boolean; + private inspectionEnabled: boolean = false; + private inspectionCallback: MessageHandler = null; + private callbacks: Map = new Map() constructor () { this.scheduleServerConnect(0) @@ -38,17 +41,17 @@ export class WebsocketApi { return date.getTime() } - public enableInspection () { + public enableInspection (eventCallback?: MessageHandler) { // TODO: Set flag to true only once response has been received from the server this.inspectionEnabled = true - this.ws.send(JSON.stringify({ id: this.mid, method: 'enable_inspection' })) - this.mid++ + this.inspectionCallback = eventCallback + this.sendMessage({ method: 'enable_inspection' }) } public disableInspection () { this.inspectionEnabled = false - this.ws.send(JSON.stringify({ id: this.mid, method: 'disable_inspection' })) - this.mid++ + this.inspectionCallback = null + this.sendMessage({ method: 'disable_inspection' }) } private scheduleServerConnect (after_ms: number = this.scheduleReconnectEveryMs) { @@ -80,11 +83,16 @@ export class WebsocketApi { } private pingServer () { - this.lastPingId = this.mid this.lastPingTime = WebsocketApi.getTime() - this.mid++ // console.log('Pinging server with id:%d', this.last_ping_id); - this.ws.send(JSON.stringify({ id: this.lastPingId, method: 'ping' })) + this.sendMessage({ method: 'ping' }, this.handlePong.bind(this)) + } + + private handlePong (message: Record) { + WebsocketApi.setServerStatusSuccess( + String((WebsocketApi.getTime() - this.lastPingTime) + ' ms')) + this.clearServerPingTimer() + this.scheduleServerPing() } private clearServerPingTimer () { @@ -93,7 +101,6 @@ export class WebsocketApi { this.serverPingTimer = null } this.lastPingTime = null - this.lastPingId = null } private onServerWSOpen (ev: MessageEvent) { @@ -102,13 +109,23 @@ export class WebsocketApi { this.scheduleServerPing(0) } + public sendMessage (data: Record, callback?: MessageHandler) { + data.id = this.mid + if (callback) { + this.callbacks.set(this.mid, callback) + } + this.mid++ + this.ws.send(JSON.stringify(data)) + } + private onServerWSMessage (ev: MessageEvent) { - const message = JSON.parse(ev.data) - if (message.id === this.lastPingId) { - WebsocketApi.setServerStatusSuccess( - String((WebsocketApi.getTime() - this.lastPingTime) + ' ms')) - this.clearServerPingTimer() - this.scheduleServerPing() + const message: Record = JSON.parse(ev.data) + if (message.push !== undefined && message.push === 'inspect_traffic' && this.inspectionCallback !== null) { + this.inspectionCallback(message) + } else if (this.callbacks.has(message.id)) { + const callback = this.callbacks.get(message.id) + this.callbacks.delete(message.id) + callback(message) } else { console.log(message) } diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index c48e55b641..ad1ecf44c0 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -23,7 +23,7 @@ export class ProxyDashboard { private static plugins: IPluginConstructor[] = []; private plugins: Map = new Map(); - private websocketApi: WebsocketApi + private readonly websocketApi: WebsocketApi constructor () { this.websocketApi = new WebsocketApi() From 3d65366c7bb5be9558489d5c7170bf7f7cfd904b Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 12:25:47 -0800 Subject: [PATCH 030/107] Add proxy.main.TestCase for unit testing Python application with proxy.py (#167) * Add demonstration of how to use proxy.py within Python application unittests * mypy fixes * test_with_proxy example * Add docs for proxy.main.TestCase. Also wait for proxy.py server to come up before running the tests. --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++ proxy/common/utils.py | 12 +++++-- proxy/main.py | 35 ++++++++++++++++++-- tests/test_embed.py | 44 +++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 tests/test_embed.py diff --git a/README.md b/README.md index 2917a922e0..240d9c893e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ Table of Contents * [Embed proxy.py](#embed-proxypy) * [Blocking Mode](#blocking-mode) * [Non-blocking Mode](#non-blocking-mode) +* [Unit testing with proxy.py](#unit-testing-with-proxypy) + * [proxy.main.TestCase](#proxymaintestcase) + * [Override Startup Flags](#override-startup-flags) + * [With unittest.TestCase](#with-unittesttestcase) * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) * [Everything is a plugin](#everything-is-a-plugin) * [Internal Architecture](#internal-architecture) @@ -760,6 +764,76 @@ Note that: 2. Is similar to calling `main` except `start` won't block. 3. It automatically shut down `proxy.py`. +Unit testing with proxy.py +========================== + +## proxy.main.TestCase + +To setup and teardown `proxy.py` for your Python unittest classes, +simply use `proxy.main.TestCase` instead of `unittest.TestCase`. +Example: + +``` +from proxy.main import TestCase + + +class TestProxyPyEmbedded(TestCase): + + def test_my_application_with_proxy(self) -> None: + self.assertTrue(True) +``` + +Note that: + +1. `proxy.main.TestCase` overrides `unittest.TestCase.run()` method to setup and teardown `proxy.py`. +2. `proxy.py` server will listen on a random available port on the system. + This random port is available as `self.proxy_port` within your test cases. +3. Only a single worker is started by default (`--num-workers 1`) for faster setup and teardown. + +## Override startup flags + +To override default startup flags, define a `PROXY_PY_STARTUP_FLAGS` variable in your test class. +Example: + +``` +class TestProxyPyEmbedded(TestCase): + + PROXY_PY_STARTUP_FLAGS = [ + '--num-workers', '1', + '--enable-web-server', + ] + + def test_my_application_with_proxy(self) -> None: + self.assertTrue(True) +``` + +See [test_embed.py](https://github.com/abhinavsingh/proxy.py/blob/develop/tests/test_embed.py) +for full working example. + +## With unittest.TestCase + +If for some reasons you are unable to directly use `proxy.main.TestCase`, +then simply override `unittest.TestCase.run` yourself to setup and teardown `proxy.py`. +Example: + +``` +import unittest + +from proxy.main import start + + +class TestProxyPyEmbedded(unittest.TestCase): + + def test_my_application_with_proxy(self) -> None: + self.assertTrue(True) + + def run(self, result: Optional[unittest.TestResult] = None) -> Any: + with start([ + '--num-workers', '1', + '--port', '... random port ...']): + super().run(result) +``` + Plugin Developer and Contributor Guide ====================================== diff --git a/proxy/common/utils.py b/proxy/common/utils.py index 062a567b9e..f87cdaffc1 100644 --- a/proxy/common/utils.py +++ b/proxy/common/utils.py @@ -14,7 +14,6 @@ from types import TracebackType from typing import Optional, Dict, Any, List, Tuple, Type, Callable -from typing_extensions import Literal from .constants import HTTP_1_1, COLON, WHITESPACE, CRLF @@ -186,10 +185,9 @@ def __exit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType]) -> Literal[False]: + exc_tb: Optional[TracebackType]) -> None: if self.conn: self.conn.close() - return False def __call__(self, func: Callable[..., Any] ) -> Callable[[socket.socket], Any]: @@ -198,3 +196,11 @@ def decorated(*args: Any, **kwargs: Any) -> Any: with self as conn: return func(conn, *args, **kwargs) return decorated + + +def get_available_port() -> int: + """Finds and returns an available port on the system.""" + with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + sock.bind(('', 0)) + _, port = sock.getsockname() + return int(port) diff --git a/proxy/main.py b/proxy/main.py index ca223cc23f..d8c847f043 100755 --- a/proxy/main.py +++ b/proxy/main.py @@ -17,10 +17,11 @@ import os import sys import time -from typing import Dict, List, Optional, Generator +import unittest +from typing import Dict, List, Optional, Generator, Any from .common.flags import Flags, init_parser -from .common.utils import text_, bytes_ +from .common.utils import text_, bytes_, get_available_port, new_socket_connection from .common.types import DictQueueType from .common.constants import DOT, COMMA from .common.constants import DEFAULT_LOG_FORMAT, DEFAULT_LOG_FILE, DEFAULT_LOG_LEVEL @@ -207,6 +208,36 @@ def start(input_args: List[str]) -> Generator[None, None, None]: os.remove(args.pid_file) +class TestCase(unittest.TestCase): + """TestCase class that automatically setup and teardown proxy.py.""" + + DEFAULT_PROXY_PY_STARTUP_FLAGS = [ + '--num-workers', '1', + ] + + def run(self, result: Optional[unittest.TestResult] = None) -> Any: + self.proxy_port = get_available_port() + + flags = getattr(self, 'PROXY_PY_STARTUP_FLAGS') \ + if hasattr(self, 'PROXY_PY_STARTUP_FLAGS') \ + else self.DEFAULT_PROXY_PY_STARTUP_FLAGS + flags.append('--port') + flags.append(str(self.proxy_port)) + + with start(flags): + # Wait for proxy.py server to come up + while True: + try: + conn = new_socket_connection(('localhost', self.proxy_port)) + break + except ConnectionRefusedError: + time.sleep(0.1) + finally: + conn.close() + # Run tests + super().run(result) + + def main(input_args: List[str]) -> None: with start(input_args): # TODO: Introduce cron feature instead of mindless sleep diff --git a/tests/test_embed.py b/tests/test_embed.py new file mode 100644 index 0000000000..e0091a2adb --- /dev/null +++ b/tests/test_embed.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from proxy.common.constants import DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE +from proxy.common.utils import socket_connection, build_http_request, build_http_response +from proxy.http.codes import httpStatusCodes +from proxy.http.methods import httpMethods +from proxy.main import TestCase + + +class TestProxyPyEmbedded(TestCase): + + PROXY_PY_STARTUP_FLAGS = [ + '--num-workers', '1', + '--enable-web-server', + ] + + def test_with_proxy(self) -> None: + """Makes a HTTP request to in-build web server via proxy server""" + with socket_connection(('localhost', self.proxy_port)) as conn: + conn.send( + build_http_request( + httpMethods.GET, b'http://localhost:%d/' % self.proxy_port, + headers={ + b'Host': b'localhost:%d' % self.proxy_port, + }) + ) + response = conn.recv(DEFAULT_CLIENT_RECVBUF_SIZE) + self.assertEqual( + response, + build_http_response( + httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', + headers={ + b'Server': PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close' + } + ) + ) From e42cfefcf6eab9037fb6257871bef8d37ed1c0ac Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 15:29:37 -0800 Subject: [PATCH 031/107] Consistent dashboard look and feel across plugins (#169) * Explicitly link version changelog in TOC * Separate out app header body builder * Ensure unsubscribe when disabling inspection. Fixes #164 * Avoid creation of new manager per dashboard instance. * Add UI header for all plugins (tabs) * Ensure app body for all plugin skeleton * Move app-header and app-body within core for consistent dashboard look and feel * Consistent UI header body for plugins * autopep8 --- README.md | 74 +++---- dashboard/dashboard.py | 33 ++-- dashboard/src/core/plugin.ts | 28 ++- dashboard/src/core/plugins/home.ts | 7 +- dashboard/src/core/plugins/inspect_traffic.ts | 9 +- dashboard/src/core/plugins/settings.ts | 9 +- dashboard/src/plugins/mock_rest_api.ts | 186 ++++++++---------- dashboard/src/plugins/shortlink.ts | 11 +- dashboard/src/plugins/traffic_control.ts | 9 +- dashboard/src/proxy.css | 12 +- dashboard/src/proxy.ts | 11 +- proxy/core/event.py | 49 +++-- proxy/main.py | 3 +- 13 files changed, 248 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index 240d9c893e..7db4870263 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ [![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg)](https://www.python.org/downloads/release/python-370/) [![Checked with mypy](https://img.shields.io/static/v1?label=mypy&message=checked&color=blue)](http://mypy-lang.org/) -[![Become a Backer](https://opencollective.com/proxypy/tiers/backer.svg?avatarHeight=36)](https://opencollective.com/proxypy) +[![Become a Backer](https://opencollective.com/proxypy/tiers/backer.svg?avatarHeight=72)](https://opencollective.com/proxypy) Table of Contents ================= @@ -78,13 +78,16 @@ Table of Contents * [WebsocketClient](#websocketclient) * [Frequently Asked Questions](#frequently-asked-questions) * [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax) + * [Unable to load plugins](#unable-to-load-plugins) * [Unable to connect with proxy.py from remote host](#unable-to-connect-with-proxypy-from-remote-host) * [Basic auth not working with a browser](#basic-auth-not-working-with-a-browser) * [Docker image not working on MacOS](#docker-image-not-working-on-macos) - * [Unable to load plugins](#unable-to-load-plugins) * [ValueError: filedescriptor out of range in select](#valueerror-filedescriptor-out-of-range-in-select) * [Flags](#flags) * [Changelog](#changelog) + * [v2.x](#v2x) + * [v1.x](#v1x) + * [v0.x](#v0x) Features ======== @@ -1016,6 +1019,23 @@ Make sure you are using `Python 3`. Verify the version before running `proxy.py` `$ python --version` +## Unable to load plugins + +Make sure plugin modules are discoverable by adding them to `PYTHONPATH`. Example: + +`PYTHONPATH=/path/to/my/app proxy --plugins my_app.proxyPlugin` + +``` +...[redacted]... - Loaded plugin proxy.HttpProxyPlugin +...[redacted]... - Loaded plugin my_app.proxyPlugin +``` + +or, make sure to pass fully-qualified path as parameter, e.g. + +`proxy --plugins /path/to/my/app/my_app.proxyPlugin` + +Note that `pip install proxy.py` don't ship [plugin_examples](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples). + ## Unable to connect with proxy.py from remote host Make sure `proxy.py` is listening on correct network interface. @@ -1043,23 +1063,6 @@ See [moby/vpnkit exhausts docker resources](https://github.com/abhinavsingh/prox and [Connection refused: The proxy could not connect](https://github.com/moby/vpnkit/issues/469) for some background. -## Unable to load plugins - -Make sure plugin modules are discoverable by adding them to `PYTHONPATH`. Example: - -`PYTHONPATH=/path/to/my/app proxy --plugins my_app.proxyPlugin` - -``` -...[redacted]... - Loaded plugin proxy.HttpProxyPlugin -...[redacted]... - Loaded plugin my_app.proxyPlugin -``` - -or, make sure to pass fully-qualified path as parameter, e.g. - -`proxy --plugins /path/to/my/app/my_app.proxyPlugin` - -Note that `pip install proxy.py` don't ship [plugin_examples](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples). - ## GCE log viewer integration for proxy.py A starter [fluentd.conf](https://github.com/abhinavsingh/proxy.py/blob/develop/fluentd.conf) @@ -1221,17 +1224,22 @@ https://github.com/abhinavsingh/proxy.py/issues/new Changelog ========= -- `v2.x` - - No longer ~~a single file module~~. - - Added support for threadless execution. - - Added dashboard app. -- `v1.x` - - `Python3` only. - - Deprecated support for ~~Python 2.x~~. - - Added support multi core accept. - - Added plugin support. -- `v0.x` - - Single file. - - Single threaded server. - -For detailed changelog refer either to release PRs or commit history. +## v2.x + +- No longer ~~a single file module~~. +- Added support for threadless execution. +- Added dashboard app. + +## v1.x + +- `Python3` only. + - Deprecated support for ~~Python 2.x~~. +- Added support multi core accept. +- Added plugin support. + +## v0.x + +- Single file. +- Single threaded server. + +For detailed changelog refer to release PRs or commit history. diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index a0ac7ecacc..b9e0fd7d8c 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -32,12 +32,13 @@ class ProxyDashboard(HttpWebServerBasePlugin): + RELAY_MANAGER: multiprocessing.managers.SyncManager = multiprocessing.Manager() + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.inspection_enabled: bool = False self.relay_thread: Optional[threading.Thread] = None self.relay_shutdown: Optional[threading.Event] = None - self.relay_manager: Optional[multiprocessing.managers.SyncManager] = None self.relay_channel: Optional[DictQueueType] = None self.relay_sub_id: Optional[str] = None @@ -102,8 +103,7 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: self.inspection_enabled = True self.relay_shutdown = threading.Event() - self.relay_manager = multiprocessing.Manager() - self.relay_channel = self.relay_manager.Queue() + self.relay_channel = ProxyDashboard.RELAY_MANAGER.Queue() self.relay_thread = threading.Thread( target=self.relay_events, args=(self.relay_shutdown, self.relay_channel, self.client)) @@ -113,36 +113,39 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: self.event_queue.subscribe( self.relay_sub_id, self.relay_channel) - self.reply({'id': message['id'], 'response': 'inspection_enabled'}) + self.reply( + {'id': message['id'], 'response': 'inspection_enabled'}) elif message['method'] == 'disable_inspection': - if self.inspection_enabled: - self.shutdown_relay() + self.shutdown_relay() self.inspection_enabled = False - self.reply({'id': message['id'], 'response': 'inspection_disabled'}) + self.reply({'id': message['id'], + 'response': 'inspection_disabled'}) else: logger.info(frame.data) logger.info(frame.opcode) + self.reply({'id': message['id'], 'response': 'not_implemented'}) + + def on_websocket_close(self) -> None: + logger.info('app ws closed') + self.shutdown_relay() def shutdown_relay(self) -> None: - assert self.relay_manager + if not self.inspection_enabled: + return + assert self.relay_shutdown assert self.relay_thread + assert self.relay_sub_id + self.event_queue.unsubscribe(self.relay_sub_id) self.relay_shutdown.set() self.relay_thread.join() - self.relay_manager.shutdown() - self.relay_manager = None self.relay_thread = None self.relay_shutdown = None self.relay_channel = None self.relay_sub_id = None - def on_websocket_close(self) -> None: - logger.info('app ws closed') - if self.inspection_enabled: - self.shutdown_relay() - def reply(self, data: Dict[str, Any]) -> None: self.client.queue( WebsocketFrame.text( diff --git a/dashboard/src/core/plugin.ts b/dashboard/src/core/plugin.ts index bb146ad4fc..13a5014f1f 100644 --- a/dashboard/src/core/plugin.ts +++ b/dashboard/src/core/plugin.ts @@ -11,8 +11,10 @@ import { WebsocketApi } from './ws' export interface IDashboardPlugin { name: string + title: string initializeTab(): JQuery - initializeSkeleton(): JQuery + initializeHeader(): JQuery + initializeBody(): JQuery activated(): void deactivated(): void } @@ -22,7 +24,6 @@ export interface IPluginConstructor { } export abstract class DashboardPlugin implements IDashboardPlugin { - public abstract readonly name: string protected websocketApi: WebsocketApi public constructor (websocketApi: WebsocketApi) { @@ -45,8 +46,29 @@ export abstract class DashboardPlugin implements IDashboardPlugin { ) } + public makeHeader (title: string) : JQuery { + return $('
    ') + .addClass('container-fluid') + .append( + $('
    ') + .addClass('row') + .append( + $('
    ') + .addClass('col-6') + .append( + $('

    ') + .addClass('h3') + .text(title) + ) + ) + ) + } + + public abstract readonly name: string + public abstract readonly title: string public abstract initializeTab() : JQuery - public abstract initializeSkeleton(): JQuery + public abstract initializeHeader(): JQuery + public abstract initializeBody(): JQuery public abstract activated(): void public abstract deactivated(): void } diff --git a/dashboard/src/core/plugins/home.ts b/dashboard/src/core/plugins/home.ts index 0ba6cf6133..dc543af883 100644 --- a/dashboard/src/core/plugins/home.ts +++ b/dashboard/src/core/plugins/home.ts @@ -11,12 +11,17 @@ import { DashboardPlugin } from '../plugin' export class HomePlugin extends DashboardPlugin { public name: string = 'home' + public title: string = 'Home' public initializeTab () : JQuery { return this.makeTab('Home', 'fa-home') } - public initializeSkeleton (): JQuery { + public initializeHeader (): JQuery { + return this.makeHeader(this.title) + } + + public initializeBody (): JQuery { return $('
    ') } diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index 06574bc11b..c57ea2ffe7 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -11,12 +11,17 @@ import { DashboardPlugin } from '../plugin' export class InspectTrafficPlugin extends DashboardPlugin { public name: string = 'inspect_traffic' + public title: string = 'Inspect Traffic' public initializeTab () : JQuery { - return this.makeTab('Inspect Traffic', 'fa-binoculars') + return this.makeTab(this.title, 'fa-binoculars') } - public initializeSkeleton (): JQuery { + public initializeHeader (): JQuery { + return this.makeHeader(this.title) + } + + public initializeBody (): JQuery { return $('
    ') } diff --git a/dashboard/src/core/plugins/settings.ts b/dashboard/src/core/plugins/settings.ts index 7cbb92d427..ab88962489 100644 --- a/dashboard/src/core/plugins/settings.ts +++ b/dashboard/src/core/plugins/settings.ts @@ -11,12 +11,17 @@ import { DashboardPlugin } from '../plugin' export class SettingsPlugin extends DashboardPlugin { public name: string = 'settings' + public title: string = 'Settings' public initializeTab () : JQuery { - return this.makeTab('Settings', 'fa-clog') + return this.makeTab(this.title, 'fa-clog') } - public initializeSkeleton (): JQuery { + public initializeHeader (): JQuery { + return this.makeHeader(this.title) + } + + public initializeBody (): JQuery { return $('
    ') } diff --git a/dashboard/src/plugins/mock_rest_api.ts b/dashboard/src/plugins/mock_rest_api.ts index cf2b43b18f..b4b92bd7cf 100644 --- a/dashboard/src/plugins/mock_rest_api.ts +++ b/dashboard/src/plugins/mock_rest_api.ts @@ -11,7 +11,8 @@ import { DashboardPlugin } from '../core/plugin' import { WebsocketApi } from '../core/ws' export class MockRestApiPlugin extends DashboardPlugin { - public name: string = 'api_development'; + public name: string = 'api_development' + public title: string = 'API Development' private specs: Map>; @@ -22,126 +23,107 @@ export class MockRestApiPlugin extends DashboardPlugin { } public initializeTab () : JQuery { - return this.makeTab('API Development', 'fa-connectdevelop') + return this.makeTab(this.title, 'fa-connectdevelop') } - public initializeSkeleton (): JQuery { + public initializeHeader (): JQuery { + return this.makeHeader(this.title) + .children('div.row') + .append( + $('
    ') + .addClass('col-6') + .addClass('text-right') + .append( + $('') + .attr('type', 'button') + .addClass('btn') + .addClass('btn-primary') + .text('Create New API') + .prepend( + $('') + .addClass('fa') + .addClass('fa-fw') + .addClass('fa-plus-circle') + ) + ) + ) + .end() + } + + public initializeBody (): JQuery { return $('
    ') - .attr('id', 'app-header') + .addClass('list-group') + .addClass('position-relative') + .append( + $('') + .attr('href', '#') + .addClass('list-group-item default text-decoration-none bg-light') + .attr('data-toggle', 'collapse') + .attr('data-target', '#api-example-com-path-specs') + .attr('data-parent', '#proxyDashboard') + .text('api.example.com ') + .append( + $('') + .addClass('badge badge-info') + .text('3 Resources') + ) + ) + .append( + $('') + .addClass('position-absolute fa fa-close ml-auto btn btn-danger remove-api-spec') + .attr('title', 'Delete api.example.com') + ) .append( $('
    ') - .addClass('container-fluid') + .addClass('collapse api-path-spec') + .attr('id', 'api-example-com-path-specs') .append( $('
    ') - .addClass('row') - .append( - $('
    ') - .addClass('col-6') - .append( - $('

    ') - .addClass('h3') - .text('API Development') - ) - ) - .append( - $('
    ') - .addClass('col-6') - .addClass('text-right') - .append( - $('') - .attr('type', 'button') - .addClass('btn') - .addClass('btn-primary') - .text('Create New API') - .prepend( - $('') - .addClass('fa') - .addClass('fa-fw') - .addClass('fa-plus-circle') - ) - ) - ) + .addClass('list-group-item bg-light') + .text('/v1/users/') + ) + .append( + $('
    ') + .addClass('list-group-item bg-light') + .text('/v1/groups/') + ) + .append( + $('
    ') + .addClass('list-group-item bg-light') + .text('/v1/messages/') ) ) .add( $('
    ') - .attr('id', 'app-body') + .addClass('list-group') + .addClass('position-relative') .append( - $('
    ') - .addClass('list-group') - .addClass('position-relative') - .append( - $('') - .attr('href', '#') - .addClass('list-group-item default text-decoration-none bg-light') - .attr('data-toggle', 'collapse') - .attr('data-target', '#api-example-com-path-specs') - .attr('data-parent', '#proxyDashboard') - .text('api.example.com') - .append( - $('') - .addClass('badge badge-info') - .text('3 Resources') - ) - ) + $('') + .attr('href', '#') + .addClass('list-group-item default text-decoration-none bg-light') + .attr('data-toggle', 'collapse') + .attr('data-target', '#my-api') + .attr('data-parent', '#proxyDashboard') + .text('my.api ') .append( - $('') - .addClass('position-absolute fa fa-close ml-auto btn btn-danger remove-api-spec') - .attr('title', 'Delete api.example.com') - ) - .append( - $('
    ') - .addClass('collapse api-path-spec') - .attr('id', 'api-example-com-path-specs') - .append( - $('
    ') - .addClass('list-group-item bg-light') - .text('/v1/users/') - ) - .append( - $('
    ') - .addClass('list-group-item bg-light') - .text('/v1/groups/') - ) - .append( - $('
    ') - .addClass('list-group-item bg-light') - .text('/v1/messages/') - ) + $('') + .addClass('badge badge-info') + .text('1 Resource') ) ) + .append( + $('') + .addClass('position-absolute fa fa-close ml-auto btn btn-danger remove-api-spec') + .attr('title', 'Delete my.api') + ) .append( $('
    ') - .addClass('list-group') - .addClass('position-relative') - .append( - $('') - .attr('href', '#') - .addClass('list-group-item default text-decoration-none bg-light') - .attr('data-toggle', 'collapse') - .attr('data-target', '#my-api') - .attr('data-parent', '#proxyDashboard') - .text('my.api') - .append( - $('') - .addClass('badge badge-info') - .text('1 Resource') - ) - ) - .append( - $('') - .addClass('position-absolute fa fa-close ml-auto btn btn-danger remove-api-spec') - .attr('title', 'Delete my.api') - ) + .addClass('collapse api-path-spec') + .attr('id', 'my-api') .append( $('
    ') - .addClass('collapse api-path-spec') - .attr('id', 'my-api') - .append( - $('
    ') - .addClass('list-group-item bg-light') - .text('/api/') - ) + .addClass('list-group-item bg-light') + .text('/api/') ) ) ) diff --git a/dashboard/src/plugins/shortlink.ts b/dashboard/src/plugins/shortlink.ts index ab01d33fdd..7c4898c798 100644 --- a/dashboard/src/plugins/shortlink.ts +++ b/dashboard/src/plugins/shortlink.ts @@ -10,13 +10,18 @@ import { DashboardPlugin } from '../core/plugin' export class ShortlinkPlugin extends DashboardPlugin { - public name: string = 'shortlink'; + public name: string = 'shortlink' + public title: string = 'Short Links' public initializeTab () : JQuery { - return this.makeTab('Short Links', 'fa-bolt') + return this.makeTab(this.title, 'fa-bolt') } - public initializeSkeleton (): JQuery { + public initializeHeader (): JQuery { + return this.makeHeader(this.title) + } + + public initializeBody (): JQuery { return $('
    ') } diff --git a/dashboard/src/plugins/traffic_control.ts b/dashboard/src/plugins/traffic_control.ts index fba6769b8a..98c539c177 100644 --- a/dashboard/src/plugins/traffic_control.ts +++ b/dashboard/src/plugins/traffic_control.ts @@ -11,12 +11,17 @@ import { DashboardPlugin } from '../core/plugin' export class TrafficControlPlugin extends DashboardPlugin { public name: string = 'traffic_control' + public title: string = 'Traffic Control' public initializeTab () : JQuery { - return this.makeTab('Traffic Controls', 'fa-lock') + return this.makeTab(this.title, 'fa-lock') } - public initializeSkeleton (): JQuery { + public initializeHeader (): JQuery { + return this.makeHeader(this.title) + } + + public initializeBody (): JQuery { return $('
    ') } diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css index 955c1144e3..e00498ccbc 100644 --- a/dashboard/src/proxy.css +++ b/dashboard/src/proxy.css @@ -20,25 +20,17 @@ padding-left: 50px; } -#app-header { +.app-header { padding-top: 10px; padding-bottom: 5px; } -#app-body .list-group { +.app-body .list-group { margin-left: 15px; margin-right: 15px; margin-bottom: 10px; } -.sunny-morning-white-gradient{ - background-image: linear-gradient(120deg,#eeeeee 0,#ffecd2 100%); -} - -.mean-fruit-white-gradient{ - background-image:linear-gradient(120deg,#eeeeee 0,#ffefbf 100%); -} - .proxy-data { display: none; } diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index ad1ecf44c0..d24deb7914 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -39,7 +39,16 @@ export class ProxyDashboard { $('
    ') .attr('id', p.name) .addClass('proxy-data') - .append(p.initializeSkeleton()) + .append( + $('
    ') + .addClass('app-header') + .append(p.initializeHeader()) + ) + .append( + $('
    ') + .addClass('app-body') + .append(p.initializeBody()) + ) ) this.plugins.set(p.name, p) } diff --git a/proxy/core/event.py b/proxy/core/event.py index b7c4f4716e..b841db3e54 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -22,10 +22,10 @@ EventNames = NamedTuple('EventNames', [ - ('WORK_STARTED', int), - ('WORK_FINISHED', int), ('SUBSCRIBE', int), ('UNSUBSCRIBE', int), + ('WORK_STARTED', int), + ('WORK_FINISHED', int), ]) eventNames = EventNames(1, 2, 3, 4) @@ -73,11 +73,21 @@ def subscribe( self, sub_id: str, channel: DictQueueType) -> None: + """Subscribe to global events.""" self.queue.put({ 'event_name': eventNames.SUBSCRIBE, 'event_payload': {'sub_id': sub_id, 'channel': channel}, }) + def unsubscribe( + self, + sub_id: str) -> None: + """Unsubscribe by subscriber id.""" + self.queue.put({ + 'event_name': eventNames.UNSUBSCRIBE, + 'event_payload': {'sub_id': sub_id}, + }) + class EventDispatcher: """Core EventDispatcher. @@ -112,26 +122,29 @@ def __init__( self.event_queue: EventQueue = event_queue self.subscribers: Dict[str, DictQueueType] = {} + def handle_event(self, ev: Dict[str, Any]) -> None: + if ev['event_name'] == eventNames.SUBSCRIBE: + self.subscribers[ev['event_payload']['sub_id']] = \ + ev['event_payload']['channel'] + elif ev['event_name'] == eventNames.UNSUBSCRIBE: + del self.subscribers[ev['event_payload']['sub_id']] + else: + # logger.info(ev) + unsub_ids: List[str] = [] + for sub_id in self.subscribers: + try: + self.subscribers[sub_id].put(ev) + except BrokenPipeError: + unsub_ids.append(sub_id) + for sub_id in unsub_ids: + del self.subscribers[sub_id] + def run(self) -> None: try: while not self.shutdown.is_set(): try: - ev = self.event_queue.queue.get(timeout=1) - if ev['event_name'] == eventNames.SUBSCRIBE: - self.subscribers[ev['event_payload']['sub_id']] = \ - ev['event_payload']['channel'] - elif ev['event_name'] == eventNames.UNSUBSCRIBE: - del self.subscribers[ev['event_payload']['sub_id']] - else: - # logger.info(ev) - unsub_ids: List[str] = [] - for sub_id in self.subscribers: - try: - self.subscribers[sub_id].put(ev) - except BrokenPipeError: - unsub_ids.append(sub_id) - for sub_id in unsub_ids: - del self.subscribers[sub_id] + ev: Dict[str, Any] = self.event_queue.queue.get(timeout=1) + self.handle_event(ev) except queue.Empty: pass except EOFError: diff --git a/proxy/main.py b/proxy/main.py index d8c847f043..cd8e3f8356 100755 --- a/proxy/main.py +++ b/proxy/main.py @@ -228,7 +228,8 @@ def run(self, result: Optional[unittest.TestResult] = None) -> Any: # Wait for proxy.py server to come up while True: try: - conn = new_socket_connection(('localhost', self.proxy_port)) + conn = new_socket_connection( + ('localhost', self.proxy_port)) break except ConnectionRefusedError: time.sleep(0.1) From 5cc9f2dde2fe76bbc2432efa03bee5f86a0d0868 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 19:20:28 -0800 Subject: [PATCH 032/107] Dashboard Inspect traffic tab + devtools (#170) * Explicitly link version changelog in TOC * Separate out app header body builder * Ensure unsubscribe when disabling inspection. Fixes #164 * Avoid creation of new manager per dashboard instance. * Add UI header for all plugins (tabs) * Ensure app body for all plugin skeleton * Move app-header and app-body within core for consistent dashboard look and feel * Consistent UI header body for plugins * autopep8 * make devtools * convert to es6 * Add inspect_traffic plugin devtools app * trigger re-build, github UI is stuck * Dynamically load devtools within inspect traffic view * Just copy devtools into public/dashboard folder * Works but not how we wanted, devtools takes over entire body and doesnt contain itself within a div --- Makefile | 5 +- dashboard/package.json | 4 +- dashboard/rollup.config.js | 75 ++++++++++--------- dashboard/src/core/devtools.ts | 42 +++++------ dashboard/src/core/plugins/home.ts | 2 +- .../src/core/plugins/inspect_traffic.json | 13 ++++ dashboard/src/core/plugins/inspect_traffic.ts | 10 +++ dashboard/src/proxy.html | 3 +- dashboard/static/jquery-3.3.1.slim.min.js | 2 - dashboard/static/jquery-3.4.1.min.js | 2 + 10 files changed, 93 insertions(+), 65 deletions(-) create mode 100644 dashboard/src/core/plugins/inspect_traffic.json delete mode 100644 dashboard/static/jquery-3.3.1.slim.min.js create mode 100644 dashboard/static/jquery-3.4.1.min.js diff --git a/Makefile b/Makefile index a50e29b578..17968ed1e4 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CA_KEY_FILE_PATH := ca-key.pem CA_CERT_FILE_PATH := ca-cert.pem CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem -.PHONY: all https-certificates ca-certificates autopep8 +.PHONY: all https-certificates ca-certificates autopep8 devtools .PHONY: lib-clean lib-test lib-package lib-release-test lib-release lib-coverage lib-lint lib-profile .PHONY: container container-run container-release .PHONY: dashboard dashboard-clean dashboard-package @@ -21,6 +21,9 @@ CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem all: lib-clean lib-test +devtools: + pushd dashboard && npm run devtools && popd + autopep8: autopep8 --recursive --in-place --aggressive proxy/*.py autopep8 --recursive --in-place --aggressive proxy/*/*.py diff --git a/dashboard/package.json b/dashboard/package.json index a744b5c460..48ddf263d7 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -10,7 +10,9 @@ "test": "jasmine tsbuild/test/test.js", "build": "npm test && rollup -c", "start": "pushd ../public && http-server -g true -i false -d false -c-1 --no-dotfiles . && popd", - "watch": "rollup -c -w" + "watch": "rollup -c -w", + "build-devtools": "tsc --target es5 --outDir tsbuild src/core/devtools.ts", + "devtools": "npm run build-devtools && node tsbuild/devtools.js" }, "repository": { "type": "git", diff --git a/dashboard/rollup.config.js b/dashboard/rollup.config.js index 26ea21d82b..46683ae156 100644 --- a/dashboard/rollup.config.js +++ b/dashboard/rollup.config.js @@ -1,39 +1,40 @@ -const typescript = require('rollup-plugin-typescript'); -const copy = require('rollup-plugin-copy'); -const obfuscatorPlugin = require('rollup-plugin-javascript-obfuscator'); +import typescript from 'rollup-plugin-typescript'; +import copy from 'rollup-plugin-copy'; +// import obfuscatorPlugin from 'rollup-plugin-javascript-obfuscator'; -module.exports = { - input: 'src/proxy.ts', - output: { - file: '../public/dashboard/proxy.js', - format: 'umd', - name: 'projectbundle', - sourcemap: true - }, - plugins: [ - typescript(), - copy({ - targets: [{ - src: 'static/**/*', - dest: '../public/dashboard', - }, { - src: 'src/proxy.html', - dest: '../public/dashboard', - }, { - src: 'src/proxy.css', - dest: '../public/dashboard', - }], - }), - obfuscatorPlugin({ - log: false, - sourceMap: true, - compact: true, - stringArray: true, - rotateStringArray: true, - transformObjectKeys: true, - stringArrayThreshold: 1, - stringArrayEncoding: 'rc4', - identifierNamesGenerator: 'mangled', - }) - ] +export const input = 'src/proxy.ts'; +export const output = { + file: '../public/dashboard/proxy.js', + format: 'umd', + name: 'projectbundle', + sourcemap: true }; +export const plugins = [ + typescript(), + copy({ + targets: [{ + src: 'static/**/*', + dest: '../public/dashboard', + }, { + src: 'src/proxy.html', + dest: '../public/dashboard', + }, { + src: 'src/proxy.css', + dest: '../public/dashboard', + }, { + src: 'src/core/plugins/inspect_traffic.json', + dest: '../public/dashboard' + }], + }), + /* obfuscatorPlugin({ + log: false, + sourceMap: true, + compact: true, + stringArray: true, + rotateStringArray: true, + transformObjectKeys: true, + stringArrayThreshold: 1, + stringArrayEncoding: 'rc4', + identifierNamesGenerator: 'mangled', + }) */ +]; diff --git a/dashboard/src/core/devtools.ts b/dashboard/src/core/devtools.ts index 595c0aacde..f597f6ab67 100644 --- a/dashboard/src/core/devtools.ts +++ b/dashboard/src/core/devtools.ts @@ -7,32 +7,30 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. */ -const path = require('path') -const fs = require('fs') +import path = require('path') +import fs = require('fs') const ncp = require('ncp').ncp + ncp.limit = 16 -const publicFolderPath = path.join(__dirname, 'public') -const destinationFolderPath = path.join(publicFolderPath, 'devtools') +function setUpDevTools () { + const destinationFolderPath = path.join(path.dirname(path.dirname(__dirname)), 'public', 'dashboard') -const publicFolderExists = fs.existsSync(publicFolderPath) -if (!publicFolderExists) { - console.error(publicFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.') - process.exit(1) -} + const destinationFolderExists = fs.existsSync(destinationFolderPath) + if (!destinationFolderExists) { + console.error(destinationFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.') + process.exit(1) + } -const destinationFolderExists = fs.existsSync(destinationFolderPath) -if (!destinationFolderExists) { - console.error(destinationFolderPath + ' folder doesn\'t exist, make sure you are in the right directory.') - process.exit(1) -} + const chromeDevTools = path.dirname(require.resolve('chrome-devtools-frontend/front_end/inspector.html')) -const chromeDevTools = path.dirname(require.resolve('chrome-devtools-frontend/front_end/inspector.html')) + console.log(chromeDevTools + ' ---> ' + destinationFolderPath) + ncp(chromeDevTools, destinationFolderPath, (err: any) => { + if (err) { + return console.error(err) + } + console.log('Copy successful!!!') + }) +} -console.log(chromeDevTools + ' ---> ' + destinationFolderPath) -ncp(chromeDevTools, destinationFolderPath, (err: any) => { - if (err) { - return console.error(err) - } - console.log('Copy successful!!!') -}) +setUpDevTools() diff --git a/dashboard/src/core/plugins/home.ts b/dashboard/src/core/plugins/home.ts index dc543af883..e9b603e548 100644 --- a/dashboard/src/core/plugins/home.ts +++ b/dashboard/src/core/plugins/home.ts @@ -10,7 +10,7 @@ import { DashboardPlugin } from '../plugin' export class HomePlugin extends DashboardPlugin { - public name: string = 'home' + public name: string = 'home'; public title: string = 'Home' public initializeTab () : JQuery { diff --git a/dashboard/src/core/plugins/inspect_traffic.json b/dashboard/src/core/plugins/inspect_traffic.json new file mode 100644 index 0000000000..31747bbbcd --- /dev/null +++ b/dashboard/src/core/plugins/inspect_traffic.json @@ -0,0 +1,13 @@ +{ + "modules" : [ + { "name": "inspector_main", "type": "autostart" }, + { "name": "emulation" }, + { "name": "mobile_throttling" }, + { "name": "cookie_table" }, + { "name": "har_importer" }, + { "name": "network" } + ], + "extends": "shell", + "has_html": true + } + \ No newline at end of file diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index c57ea2ffe7..c537b071c3 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -9,6 +9,8 @@ */ import { DashboardPlugin } from '../plugin' +declare const Root: any + export class InspectTrafficPlugin extends DashboardPlugin { public name: string = 'inspect_traffic' public title: string = 'Inspect Traffic' @@ -23,10 +25,18 @@ export class InspectTrafficPlugin extends DashboardPlugin { public initializeBody (): JQuery { return $('
    ') + .attr('id', '-blink-dev-tools') + .addClass('undocked') + .add( + $('') + .attr('type', 'module') + .attr('src', 'root.js') + ) } public activated (): void { this.websocketApi.enableInspection(this.handleEvents.bind(this)) + Root.Runtime.startApplication('inspect_traffic') } public deactivated (): void { diff --git a/dashboard/src/proxy.html b/dashboard/src/proxy.html index 9142b3f63c..42fa84a41e 100644 --- a/dashboard/src/proxy.html +++ b/dashboard/src/proxy.html @@ -10,6 +10,7 @@ + @@ -47,7 +48,7 @@
  • - + diff --git a/dashboard/static/jquery-3.3.1.slim.min.js b/dashboard/static/jquery-3.3.1.slim.min.js deleted file mode 100644 index f4ca9b24ba..0000000000 --- a/dashboard/static/jquery-3.3.1.slim.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.3.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector | (c) JS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(e,t){"use strict";var n=[],r=e.document,i=Object.getPrototypeOf,o=n.slice,a=n.concat,u=n.push,s=n.indexOf,l={},c=l.toString,f=l.hasOwnProperty,d=f.toString,p=d.call(Object),h={},g=function e(t){return"function"==typeof t&&"number"!=typeof t.nodeType},v=function e(t){return null!=t&&t===t.window},y={type:!0,src:!0,noModule:!0};function m(e,t,n){var i,o=(t=t||r).createElement("script");if(o.text=e,n)for(i in y)n[i]&&(o[i]=n[i]);t.head.appendChild(o).parentNode.removeChild(o)}function b(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[c.call(e)]||"object":typeof e}var x="3.3.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/Tween,-effects/animatedSelector",w=function(e,t){return new w.fn.init(e,t)},C=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;w.fn=w.prototype={jquery:x,constructor:w,length:0,toArray:function(){return o.call(this)},get:function(e){return null==e?o.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=w.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return w.each(this,e)},map:function(e){return this.pushStack(w.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(o.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n0&&t-1 in e)}var E=function(e){var t,n,r,i,o,a,u,s,l,c,f,d,p,h,g,v,y,m,b,x="sizzle"+1*new Date,w=e.document,C=0,T=0,E=ae(),N=ae(),k=ae(),A=function(e,t){return e===t&&(f=!0),0},D={}.hasOwnProperty,S=[],L=S.pop,j=S.push,q=S.push,O=S.slice,P=function(e,t){for(var n=0,r=e.length;n+~]|"+I+")"+I+"*"),_=new RegExp("="+I+"*([^\\]'\"]*?)"+I+"*\\]","g"),U=new RegExp(M),V=new RegExp("^"+R+"$"),X={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+B),PSEUDO:new RegExp("^"+M),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+I+"*(even|odd|(([+-]|)(\\d*)n|)"+I+"*(?:([+-]|)"+I+"*(\\d+)|))"+I+"*\\)|)","i"),bool:new RegExp("^(?:"+H+")$","i"),needsContext:new RegExp("^"+I+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+I+"*((?:-\\d)?\\d*)"+I+"*\\)|)(?=[^-]|$)","i")},Q=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,G=/^[^{]+\{\s*\[native \w/,K=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,J=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+I+"?|("+I+")|.)","ig"),ee=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},te=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ne=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},re=function(){d()},ie=me(function(e){return!0===e.disabled&&("form"in e||"label"in e)},{dir:"parentNode",next:"legend"});try{q.apply(S=O.call(w.childNodes),w.childNodes),S[w.childNodes.length].nodeType}catch(e){q={apply:S.length?function(e,t){j.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function oe(e,t,r,i){var o,u,l,c,f,h,y,m=t&&t.ownerDocument,C=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==C&&9!==C&&11!==C)return r;if(!i&&((t?t.ownerDocument||t:w)!==p&&d(t),t=t||p,g)){if(11!==C&&(f=K.exec(e)))if(o=f[1]){if(9===C){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(m&&(l=m.getElementById(o))&&b(t,l)&&l.id===o)return r.push(l),r}else{if(f[2])return q.apply(r,t.getElementsByTagName(e)),r;if((o=f[3])&&n.getElementsByClassName&&t.getElementsByClassName)return q.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!k[e+" "]&&(!v||!v.test(e))){if(1!==C)m=t,y=e;else if("object"!==t.nodeName.toLowerCase()){(c=t.getAttribute("id"))?c=c.replace(te,ne):t.setAttribute("id",c=x),u=(h=a(e)).length;while(u--)h[u]="#"+c+" "+ye(h[u]);y=h.join(","),m=J.test(e)&&ge(t.parentNode)||t}if(y)try{return q.apply(r,m.querySelectorAll(y)),r}catch(e){}finally{c===x&&t.removeAttribute("id")}}}return s(e.replace($,"$1"),t,r,i)}function ae(){var e=[];function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}return t}function ue(e){return e[x]=!0,e}function se(e){var t=p.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function le(e,t){var n=e.split("|"),i=n.length;while(i--)r.attrHandle[n[i]]=t}function ce(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function fe(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function de(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function pe(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&ie(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return ue(function(t){return t=+t,ue(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function ge(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}n=oe.support={},o=oe.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},d=oe.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==p&&9===a.nodeType&&a.documentElement?(p=a,h=p.documentElement,g=!o(p),w!==p&&(i=p.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=se(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=se(function(e){return e.appendChild(p.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=G.test(p.getElementsByClassName),n.getById=se(function(e){return h.appendChild(e).id=x,!p.getElementsByName||!p.getElementsByName(x).length}),n.getById?(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){return e.getAttribute("id")===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}}):(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){var n="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),r.find.TAG=n.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):n.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&g)return t.getElementsByClassName(e)},y=[],v=[],(n.qsa=G.test(p.querySelectorAll))&&(se(function(e){h.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+I+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+I+"*(?:value|"+H+")"),e.querySelectorAll("[id~="+x+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+x+"+*").length||v.push(".#.+[+~]")}),se(function(e){e.innerHTML="";var t=p.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+I+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),h.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(n.matchesSelector=G.test(m=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&se(function(e){n.disconnectedMatch=m.call(e,"*"),m.call(e,"[s!='']:x"),y.push("!=",M)}),v=v.length&&new RegExp(v.join("|")),y=y.length&&new RegExp(y.join("|")),t=G.test(h.compareDocumentPosition),b=t||G.test(h.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},A=t?function(e,t){if(e===t)return f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===p||e.ownerDocument===w&&b(w,e)?-1:t===p||t.ownerDocument===w&&b(w,t)?1:c?P(c,e)-P(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],u=[t];if(!i||!o)return e===p?-1:t===p?1:i?-1:o?1:c?P(c,e)-P(c,t):0;if(i===o)return ce(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)u.unshift(n);while(a[r]===u[r])r++;return r?ce(a[r],u[r]):a[r]===w?-1:u[r]===w?1:0},p):p},oe.matches=function(e,t){return oe(e,null,null,t)},oe.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&d(e),t=t.replace(_,"='$1']"),n.matchesSelector&&g&&!k[t+" "]&&(!y||!y.test(t))&&(!v||!v.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return oe(t,p,null,[e]).length>0},oe.contains=function(e,t){return(e.ownerDocument||e)!==p&&d(e),b(e,t)},oe.attr=function(e,t){(e.ownerDocument||e)!==p&&d(e);var i=r.attrHandle[t.toLowerCase()],o=i&&D.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},oe.escape=function(e){return(e+"").replace(te,ne)},oe.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},oe.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(A),f){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return c=null,e},i=oe.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else while(t=e[r++])n+=i(t);return n},(r=oe.selectors={cacheLength:50,createPseudo:ue,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Z,ee),e[3]=(e[3]||e[4]||e[5]||"").replace(Z,ee),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||oe.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&oe.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return X.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&U.test(n)&&(t=a(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Z,ee).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+I+")"+e+"("+I+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=oe.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace(W," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),u="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,s){var l,c,f,d,p,h,g=o!==a?"nextSibling":"previousSibling",v=t.parentNode,y=u&&t.nodeName.toLowerCase(),m=!s&&!u,b=!1;if(v){if(o){while(g){d=t;while(d=d[g])if(u?d.nodeName.toLowerCase()===y:1===d.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?v.firstChild:v.lastChild],a&&m){b=(p=(l=(c=(f=(d=v)[x]||(d[x]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]||[])[0]===C&&l[1])&&l[2],d=p&&v.childNodes[p];while(d=++p&&d&&d[g]||(b=p=0)||h.pop())if(1===d.nodeType&&++b&&d===t){c[e]=[C,p,b];break}}else if(m&&(b=p=(l=(c=(f=(d=t)[x]||(d[x]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]||[])[0]===C&&l[1]),!1===b)while(d=++p&&d&&d[g]||(b=p=0)||h.pop())if((u?d.nodeName.toLowerCase()===y:1===d.nodeType)&&++b&&(m&&((c=(f=d[x]||(d[x]={}))[d.uniqueID]||(f[d.uniqueID]={}))[e]=[C,b]),d===t))break;return(b-=i)===r||b%r==0&&b/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||oe.error("unsupported pseudo: "+e);return i[x]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?ue(function(e,n){var r,o=i(e,t),a=o.length;while(a--)e[r=P(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:ue(function(e){var t=[],n=[],r=u(e.replace($,"$1"));return r[x]?ue(function(e,t,n,i){var o,a=r(e,null,i,[]),u=e.length;while(u--)(o=a[u])&&(e[u]=!(t[u]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:ue(function(e){return function(t){return oe(e,t).length>0}}),contains:ue(function(e){return e=e.replace(Z,ee),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:ue(function(e){return V.test(e||"")||oe.error("unsupported lang: "+e),e=e.replace(Z,ee).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:pe(!1),disabled:pe(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return Y.test(e.nodeName)},input:function(e){return Q.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he(function(){return[0]}),last:he(function(e,t){return[t-1]}),eq:he(function(e,t,n){return[n<0?n+t:n]}),even:he(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function xe(e,t,n){for(var r=0,i=t.length;r-1&&(o[l]=!(a[l]=f))}}else y=we(y===a?y.splice(h,y.length):y),i?i(null,a,y,s):q.apply(a,y)})}function Te(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],u=a||r.relative[" "],s=a?1:0,c=me(function(e){return e===t},u,!0),f=me(function(e){return P(t,e)>-1},u,!0),d=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];s1&&be(d),s>1&&ye(e.slice(0,s-1).concat({value:" "===e[s-2].type?"*":""})).replace($,"$1"),n,s0,i=e.length>0,o=function(o,a,u,s,c){var f,h,v,y=0,m="0",b=o&&[],x=[],w=l,T=o||i&&r.find.TAG("*",c),E=C+=null==w?1:Math.random()||.1,N=T.length;for(c&&(l=a===p||a||c);m!==N&&null!=(f=T[m]);m++){if(i&&f){h=0,a||f.ownerDocument===p||(d(f),u=!g);while(v=e[h++])if(v(f,a||p,u)){s.push(f);break}c&&(C=E)}n&&((f=!v&&f)&&y--,o&&b.push(f))}if(y+=m,n&&m!==y){h=0;while(v=t[h++])v(b,x,a,u);if(o){if(y>0)while(m--)b[m]||x[m]||(x[m]=L.call(s));x=we(x)}q.apply(s,x),c&&!o&&x.length>0&&y+t.length>1&&oe.uniqueSort(s)}return c&&(C=E,l=w),b};return n?ue(o):o}return u=oe.compile=function(e,t){var n,r=[],i=[],o=k[e+" "];if(!o){t||(t=a(e)),n=t.length;while(n--)(o=Te(t[n]))[x]?r.push(o):i.push(o);(o=k(e,Ee(i,r))).selector=e}return o},s=oe.select=function(e,t,n,i){var o,s,l,c,f,d="function"==typeof e&&e,p=!i&&a(e=d.selector||e);if(n=n||[],1===p.length){if((s=p[0]=p[0].slice(0)).length>2&&"ID"===(l=s[0]).type&&9===t.nodeType&&g&&r.relative[s[1].type]){if(!(t=(r.find.ID(l.matches[0].replace(Z,ee),t)||[])[0]))return n;d&&(t=t.parentNode),e=e.slice(s.shift().value.length)}o=X.needsContext.test(e)?0:s.length;while(o--){if(l=s[o],r.relative[c=l.type])break;if((f=r.find[c])&&(i=f(l.matches[0].replace(Z,ee),J.test(s[0].type)&&ge(t.parentNode)||t))){if(s.splice(o,1),!(e=i.length&&ye(s)))return q.apply(n,i),n;break}}}return(d||u(e,p))(i,t,!g,n,!t||J.test(e)&&ge(t.parentNode)||t),n},n.sortStable=x.split("").sort(A).join("")===x,n.detectDuplicates=!!f,d(),n.sortDetached=se(function(e){return 1&e.compareDocumentPosition(p.createElement("fieldset"))}),se(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||le("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&se(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||le("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),se(function(e){return null==e.getAttribute("disabled")})||le(H,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),oe}(e);w.find=E,w.expr=E.selectors,w.expr[":"]=w.expr.pseudos,w.uniqueSort=w.unique=E.uniqueSort,w.text=E.getText,w.isXMLDoc=E.isXML,w.contains=E.contains,w.escapeSelector=E.escape;var N=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&w(e).is(n))break;r.push(e)}return r},k=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},A=w.expr.match.needsContext;function D(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var S=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function L(e,t,n){return g(t)?w.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?w.grep(e,function(e){return e===t!==n}):"string"!=typeof t?w.grep(e,function(e){return s.call(t,e)>-1!==n}):w.filter(t,e,n)}w.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?w.find.matchesSelector(r,e)?[r]:[]:w.find.matches(e,w.grep(t,function(e){return 1===e.nodeType}))},w.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(w(e).filter(function(){for(t=0;t1?w.uniqueSort(n):n},filter:function(e){return this.pushStack(L(this,e||[],!1))},not:function(e){return this.pushStack(L(this,e||[],!0))},is:function(e){return!!L(this,"string"==typeof e&&A.test(e)?w(e):e||[],!1).length}});var j,q=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(w.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:q.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof w?t[0]:t,w.merge(this,w.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),S.test(i[1])&&w.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(o=r.getElementById(i[2]))&&(this[0]=o,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==n.ready?n.ready(e):e(w):w.makeArray(e,this)}).prototype=w.fn,j=w(r);var O=/^(?:parents|prev(?:Until|All))/,P={children:!0,contents:!0,next:!0,prev:!0};w.fn.extend({has:function(e){var t=w(e,this),n=t.length;return this.filter(function(){for(var e=0;e-1:1===n.nodeType&&w.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?w.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?s.call(w(e),this[0]):s.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(w.uniqueSort(w.merge(this.get(),w(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function H(e,t){while((e=e[t])&&1!==e.nodeType);return e}w.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return N(e,"parentNode")},parentsUntil:function(e,t,n){return N(e,"parentNode",n)},next:function(e){return H(e,"nextSibling")},prev:function(e){return H(e,"previousSibling")},nextAll:function(e){return N(e,"nextSibling")},prevAll:function(e){return N(e,"previousSibling")},nextUntil:function(e,t,n){return N(e,"nextSibling",n)},prevUntil:function(e,t,n){return N(e,"previousSibling",n)},siblings:function(e){return k((e.parentNode||{}).firstChild,e)},children:function(e){return k(e.firstChild)},contents:function(e){return D(e,"iframe")?e.contentDocument:(D(e,"template")&&(e=e.content||e),w.merge([],e.childNodes))}},function(e,t){w.fn[e]=function(n,r){var i=w.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=w.filter(r,i)),this.length>1&&(P[e]||w.uniqueSort(i),O.test(e)&&i.reverse()),this.pushStack(i)}});var I=/[^\x20\t\r\n\f]+/g;function R(e){var t={};return w.each(e.match(I)||[],function(e,n){t[n]=!0}),t}w.Callbacks=function(e){e="string"==typeof e?R(e):w.extend({},e);var t,n,r,i,o=[],a=[],u=-1,s=function(){for(i=i||e.once,r=t=!0;a.length;u=-1){n=a.shift();while(++u-1)o.splice(n,1),n<=u&&u--}),this},has:function(e){return e?w.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||t||(o=n=""),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||s()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l};function B(e){return e}function M(e){throw e}function W(e,t,n,r){var i;try{e&&g(i=e.promise)?i.call(e).done(t).fail(n):e&&g(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}w.extend({Deferred:function(t){var n=[["notify","progress",w.Callbacks("memory"),w.Callbacks("memory"),2],["resolve","done",w.Callbacks("once memory"),w.Callbacks("once memory"),0,"resolved"],["reject","fail",w.Callbacks("once memory"),w.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},"catch":function(e){return i.then(null,e)},pipe:function(){var e=arguments;return w.Deferred(function(t){w.each(n,function(n,r){var i=g(e[r[4]])&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&g(e.promise)?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var u=this,s=arguments,l=function(){var e,l;if(!(t=o&&(r!==M&&(u=void 0,s=[e]),n.rejectWith(u,s))}};t?c():(w.Deferred.getStackHook&&(c.stackTrace=w.Deferred.getStackHook()),e.setTimeout(c))}}return w.Deferred(function(e){n[0][3].add(a(0,e,g(i)?i:B,e.notifyWith)),n[1][3].add(a(0,e,g(t)?t:B)),n[2][3].add(a(0,e,g(r)?r:M))}).promise()},promise:function(e){return null!=e?w.extend(e,i):i}},o={};return w.each(n,function(e,t){var a=t[2],u=t[5];i[t[1]]=a.add,u&&a.add(function(){r=u},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),i=o.call(arguments),a=w.Deferred(),u=function(e){return function(n){r[e]=this,i[e]=arguments.length>1?o.call(arguments):n,--t||a.resolveWith(r,i)}};if(t<=1&&(W(e,a.done(u(n)).resolve,a.reject,!t),"pending"===a.state()||g(i[n]&&i[n].then)))return a.then();while(n--)W(i[n],u(n),a.reject);return a.promise()}});var $=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;w.Deferred.exceptionHook=function(t,n){e.console&&e.console.warn&&t&&$.test(t.name)&&e.console.warn("jQuery.Deferred exception: "+t.message,t.stack,n)},w.readyException=function(t){e.setTimeout(function(){throw t})};var F=w.Deferred();w.fn.ready=function(e){return F.then(e)["catch"](function(e){w.readyException(e)}),this},w.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--w.readyWait:w.isReady)||(w.isReady=!0,!0!==e&&--w.readyWait>0||F.resolveWith(r,[w]))}}),w.ready.then=F.then;function z(){r.removeEventListener("DOMContentLoaded",z),e.removeEventListener("load",z),w.ready()}"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll?e.setTimeout(w.ready):(r.addEventListener("DOMContentLoaded",z),e.addEventListener("load",z));var _=function(e,t,n,r,i,o,a){var u=0,s=e.length,l=null==n;if("object"===b(n)){i=!0;for(u in n)_(e,t,u,n[u],!0,o,a)}else if(void 0!==r&&(i=!0,g(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(w(e),n)})),t))for(;u1,null,!0)},removeData:function(e){return this.each(function(){J.remove(this,e)})}}),w.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=K.get(e,t),n&&(!r||Array.isArray(n)?r=K.access(e,t,w.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=w.queue(e,t),r=n.length,i=n.shift(),o=w._queueHooks(e,t),a=function(){w.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return K.get(e,n)||K.access(e,n,{empty:w.Callbacks("once memory").add(function(){K.remove(e,[t+"queue",n])})})}}),w.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length\x20\t\r\n\f]+)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&D(e,t)?w.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n-1)i&&i.push(o);else if(l=w.contains(o.ownerDocument,o),a=ve(f.appendChild(o),"script"),l&&ye(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}!function(){var e=r.createDocumentFragment().appendChild(r.createElement("div")),t=r.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),h.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var xe=r.documentElement,we=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Te=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function Ne(){return!1}function ke(){try{return r.activeElement}catch(e){}}function Ae(e,t,n,r,i,o){var a,u;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(u in t)Ae(e,u,n,r,t[u],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Ne;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,u,s,l,c,f,d,p,h,g,v=K.get(e);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(xe,i),n.guid||(n.guid=w.guid++),(s=v.events)||(s=v.events={}),(a=v.handle)||(a=v.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(I)||[""]).length;while(l--)p=g=(u=Te.exec(t[l])||[])[1],h=(u[2]||"").split(".").sort(),p&&(f=w.event.special[p]||{},p=(i?f.delegateType:f.bindType)||p,f=w.event.special[p]||{},c=w.extend({type:p,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(d=s[p])||((d=s[p]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(p,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?d.splice(d.delegateCount++,0,c):d.push(c),w.event.global[p]=!0)}},remove:function(e,t,n,r,i){var o,a,u,s,l,c,f,d,p,h,g,v=K.hasData(e)&&K.get(e);if(v&&(s=v.events)){l=(t=(t||"").match(I)||[""]).length;while(l--)if(u=Te.exec(t[l])||[],p=g=u[1],h=(u[2]||"").split(".").sort(),p){f=w.event.special[p]||{},d=s[p=(r?f.delegateType:f.bindType)||p]||[],u=u[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=d.length;while(o--)c=d[o],!i&&g!==c.origType||n&&n.guid!==c.guid||u&&!u.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(d.splice(o,1),c.selector&&d.delegateCount--,f.remove&&f.remove.call(e,c));a&&!d.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||w.removeEvent(e,p,v.handle),delete s[p])}else for(p in s)w.event.remove(e,p+t[l],n,r,!0);w.isEmptyObject(s)&&K.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,u,s=new Array(arguments.length),l=(K.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(s[0]=t,n=1;n=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&u.push({elem:l,handlers:o})}return l=this,s\x20\t\r\n\f]*)[^>]*)\/>/gi,Se=/\s*$/g;function qe(e,t){return D(e,"table")&&D(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function Oe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Pe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function He(e,t){var n,r,i,o,a,u,s,l;if(1===t.nodeType){if(K.hasData(e)&&(o=K.access(e),a=K.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n1&&"string"==typeof v&&!h.checkClone&&Le.test(v))return e.each(function(i){var o=e.eq(i);y&&(t[0]=v.call(this,i,o.html())),Re(o,t,n,r)});if(d&&(i=be(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(s=(u=w.map(ve(i,"script"),Oe)).length;f")},clone:function(e,t,n){var r,i,o,a,u=e.cloneNode(!0),s=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ve(u),r=0,i=(o=ve(e)).length;r0&&ye(a,!s&&ve(e,"script")),u},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[K.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[K.expando]=void 0}n[J.expando]&&(n[J.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Be(this,e,!0)},remove:function(e){return Be(this,e)},text:function(e){return _(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||qe(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=qe(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ve(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return _(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Se.test(e)&&!ge[(pe.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n=0&&(s+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-s-u-.5))),s}function et(e,t,n){var r=We(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(Me.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,u=Q(t),s=Ue.test(t),l=e.style;if(s||(t=Ke(u)),a=w.cssHooks[t]||w.cssHooks[u],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=se(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[u]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(s?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,u=Q(t);return Ue.test(t)||(t=Ke(u)),(a=w.cssHooks[t]||w.cssHooks[u])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Xe&&(i=Xe[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!_e.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):ue(e,Ve,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=We(e),a="border-box"===w.css(e,"boxSizing",!1,o),u=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(u-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),u&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Je(e,n,u)}}}),w.cssHooks.marginLeft=ze(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-ue(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Je)}),w.fn.extend({css:function(e,t){return _(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=We(e),i=t.length;a1)}}),w.fn.delay=function(t,n){return t=w.fx?w.fx.speeds[t]||t:t,n=n||"fx",this.queue(n,function(n,r){var i=e.setTimeout(n,t);r.stop=function(){e.clearTimeout(i)}})},function(){var e=r.createElement("input"),t=r.createElement("select").appendChild(r.createElement("option"));e.type="checkbox",h.checkOn=""!==e.value,h.optSelected=t.selected,(e=r.createElement("input")).value="t",e.type="radio",h.radioValue="t"===e.value}();var tt,nt=w.expr.attrHandle;w.fn.extend({attr:function(e,t){return _(this,w.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?tt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&D(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(I);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),tt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=nt[t]||w.find.attr;nt[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=nt[a],nt[a]=i,i=null!=n(e,t,r)?a:null,nt[a]=o),i}});var rt=/^(?:input|select|textarea|button)$/i,it=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return _(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):rt.test(e.nodeName)||it.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function ot(e){return(e.match(I)||[]).join(" ")}function at(e){return e.getAttribute&&e.getAttribute("class")||""}function ut(e){return Array.isArray(e)?e:"string"==typeof e?e.match(I)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,u,s=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,at(this)))});if((t=ut(e)).length)while(n=this[s++])if(i=at(n),r=1===n.nodeType&&" "+ot(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(u=ot(r))&&n.setAttribute("class",u)}return this},removeClass:function(e){var t,n,r,i,o,a,u,s=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,at(this)))});if(!arguments.length)return this.attr("class","");if((t=ut(e)).length)while(n=this[s++])if(i=at(n),r=1===n.nodeType&&" "+ot(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(u=ot(r))&&n.setAttribute("class",u)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,at(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=ut(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=at(this))&&K.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":K.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+ot(at(n))+" ").indexOf(t)>-1)return!0;return!1}});var st=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(st,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:ot(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,u=a?null:[],s=a?o+1:i.length;for(r=o<0?s:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var lt=/^(?:focusinfocus|focusoutblur)$/,ct=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,u,s,l,c,d,p,h,y=[i||r],m=f.call(t,"type")?t.type:t,b=f.call(t,"namespace")?t.namespace.split("."):[];if(u=h=s=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!lt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(b=m.split(".")).shift(),b.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=b.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+b.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),p=w.event.special[m]||{},o||!p.trigger||!1!==p.trigger.apply(i,n))){if(!o&&!p.noBubble&&!v(i)){for(l=p.delegateType||m,lt.test(l+m)||(u=u.parentNode);u;u=u.parentNode)y.push(u),s=u;s===(i.ownerDocument||r)&&y.push(s.defaultView||s.parentWindow||e)}a=0;while((u=y[a++])&&!t.isPropagationStopped())h=u,t.type=a>1?l:p.bindType||m,(d=(K.get(u,"events")||{})[t.type]&&K.get(u,"handle"))&&d.apply(u,n),(d=c&&u[c])&&d.apply&&Y(u)&&(t.result=d.apply(u,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||p._default&&!1!==p._default.apply(y.pop(),n)||!Y(i)||c&&g(i[m])&&!v(i)&&((s=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,ct),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,ct),w.event.triggered=void 0,s&&(i[c]=s)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=K.access(r,t);i||r.addEventListener(e,n,!0),K.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=K.access(r,t)-1;i?K.access(r,t,i):(r.removeEventListener(e,n,!0),K.remove(r,t))}}});var ft=/\[\]$/,dt=/\r?\n/g,pt=/^(?:submit|button|image|reset|file)$/i,ht=/^(?:input|select|textarea|keygen)/i;function gt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||ft.test(e)?r(e,i):gt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==b(t))r(e,t);else for(i in t)gt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)gt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&ht.test(this.nodeName)&&!pt.test(e)&&(this.checked||!de.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(dt,"\r\n")}}):{name:t.name,value:n.replace(dt,"\r\n")}}).get()}}),w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},h.createHTMLDocument=function(){var e=r.implementation.createHTMLDocument("").body;return e.innerHTML="
    ",2===e.childNodes.length}(),w.parseHTML=function(e,t,n){if("string"!=typeof e)return[];"boolean"==typeof t&&(n=t,t=!1);var i,o,a;return t||(h.createHTMLDocument?((i=(t=r.implementation.createHTMLDocument("")).createElement("base")).href=r.location.href,t.head.appendChild(i)):t=r),o=S.exec(e),a=!n&&[],o?[t.createElement(o[1])]:(o=be([e],t,a),a&&a.length&&w(a).remove(),w.merge([],o.childNodes))},w.offset={setOffset:function(e,t,n){var r,i,o,a,u,s,l,c=w.css(e,"position"),f=w(e),d={};"static"===c&&(e.style.position="relative"),u=f.offset(),o=w.css(e,"top"),s=w.css(e,"left"),(l=("absolute"===c||"fixed"===c)&&(o+s).indexOf("auto")>-1)?(a=(r=f.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(s)||0),g(t)&&(t=t.call(e,n,w.extend({},u))),null!=t.top&&(d.top=t.top-u.top+a),null!=t.left&&(d.left=t.left-u.left+i),"using"in t?t.using.call(e,d):f.css(d)}},w.fn.extend({offset:function(e){if(arguments.length)return void 0===e?this:this.each(function(t){w.offset.setOffset(this,e,t)});var t,n,r=this[0];if(r)return r.getClientRects().length?(t=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:t.top+n.pageYOffset,left:t.left+n.pageXOffset}):{top:0,left:0}},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===w.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===w.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=w(e).offset()).top+=w.css(e,"borderTopWidth",!0),i.left+=w.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-w.css(r,"marginTop",!0),left:t.left-i.left-w.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===w.css(e,"position"))e=e.offsetParent;return e||xe})}}),w.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,t){var n="pageYOffset"===t;w.fn[e]=function(r){return _(this,function(e,r,i){var o;if(v(e)?o=e:9===e.nodeType&&(o=e.defaultView),void 0===i)return o?o[t]:e[r];o?o.scrollTo(n?o.pageXOffset:i,n?i:o.pageYOffset):e[r]=i},e,r,arguments.length)}}),w.each(["top","left"],function(e,t){w.cssHooks[t]=ze(h.pixelPosition,function(e,n){if(n)return n=Fe(e,t),Me.test(n)?w(e).position()[t]+"px":n})}),w.each({Height:"height",Width:"width"},function(e,t){w.each({padding:"inner"+e,content:t,"":"outer"+e},function(n,r){w.fn[r]=function(i,o){var a=arguments.length&&(n||"boolean"!=typeof i),u=n||(!0===i||!0===o?"margin":"border");return _(this,function(t,n,i){var o;return v(t)?0===r.indexOf("outer")?t["inner"+e]:t.document.documentElement["client"+e]:9===t.nodeType?(o=t.documentElement,Math.max(t.body["scroll"+e],o["scroll"+e],t.body["offset"+e],o["offset"+e],o["client"+e])):void 0===i?w.css(t,n,u):w.style(t,n,i,u)},t,a?i:void 0,a)}})}),w.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,t){w.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),w.fn.extend({hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),w.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)}}),w.proxy=function(e,t){var n,r,i;if("string"==typeof t&&(n=e[t],t=e,e=n),g(e))return r=o.call(arguments,2),i=function(){return e.apply(t||this,r.concat(o.call(arguments)))},i.guid=e.guid=e.guid||w.guid++,i},w.holdReady=function(e){e?w.readyWait++:w.ready(!0)},w.isArray=Array.isArray,w.parseJSON=JSON.parse,w.nodeName=D,w.isFunction=g,w.isWindow=v,w.camelCase=Q,w.type=b,w.now=Date.now,w.isNumeric=function(e){var t=w.type(e);return("number"===t||"string"===t)&&!isNaN(e-parseFloat(e))},"function"==typeof define&&define.amd&&define("jquery",[],function(){return w});var vt=e.jQuery,yt=e.$;return w.noConflict=function(t){return e.$===w&&(e.$=yt),t&&e.jQuery===w&&(e.jQuery=vt),w},t||(e.jQuery=e.$=w),w}); diff --git a/dashboard/static/jquery-3.4.1.min.js b/dashboard/static/jquery-3.4.1.min.js new file mode 100644 index 0000000000..a1c07fd803 --- /dev/null +++ b/dashboard/static/jquery-3.4.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
    ",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 0 Date: Tue, 12 Nov 2019 20:02:28 -0800 Subject: [PATCH 033/107] Load devtools within iframe --- dashboard/rollup.config.js | 8 +++++++- dashboard/src/core/devtools.ts | 2 +- .../src/core/plugins/inspect_traffic.html | 18 ++++++++++++++++++ dashboard/src/core/plugins/inspect_traffic.js | 10 ++++++++++ dashboard/src/core/plugins/inspect_traffic.ts | 19 ++++++++++--------- dashboard/src/proxy.css | 17 +++++++++-------- 6 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 dashboard/src/core/plugins/inspect_traffic.html create mode 100644 dashboard/src/core/plugins/inspect_traffic.js diff --git a/dashboard/rollup.config.js b/dashboard/rollup.config.js index 46683ae156..45160fe9ef 100644 --- a/dashboard/rollup.config.js +++ b/dashboard/rollup.config.js @@ -23,7 +23,13 @@ export const plugins = [ dest: '../public/dashboard', }, { src: 'src/core/plugins/inspect_traffic.json', - dest: '../public/dashboard' + dest: '../public/dashboard/devtools' + }, { + src: 'src/core/plugins/inspect_traffic.js', + dest: '../public/dashboard/devtools' + }, { + src: 'src/core/plugins/inspect_traffic.html', + dest: '../public/dashboard/devtools' }], }), /* obfuscatorPlugin({ diff --git a/dashboard/src/core/devtools.ts b/dashboard/src/core/devtools.ts index f597f6ab67..f231123781 100644 --- a/dashboard/src/core/devtools.ts +++ b/dashboard/src/core/devtools.ts @@ -14,7 +14,7 @@ const ncp = require('ncp').ncp ncp.limit = 16 function setUpDevTools () { - const destinationFolderPath = path.join(path.dirname(path.dirname(__dirname)), 'public', 'dashboard') + const destinationFolderPath = path.join(path.dirname(path.dirname(__dirname)), 'public', 'dashboard', 'devtools') const destinationFolderExists = fs.existsSync(destinationFolderPath) if (!destinationFolderExists) { diff --git a/dashboard/src/core/plugins/inspect_traffic.html b/dashboard/src/core/plugins/inspect_traffic.html new file mode 100644 index 0000000000..18e8689265 --- /dev/null +++ b/dashboard/src/core/plugins/inspect_traffic.html @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/dashboard/src/core/plugins/inspect_traffic.js b/dashboard/src/core/plugins/inspect_traffic.js new file mode 100644 index 0000000000..0e252ce6e2 --- /dev/null +++ b/dashboard/src/core/plugins/inspect_traffic.js @@ -0,0 +1,10 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +Root.Runtime.startApplication('inspect_traffic'); diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index c537b071c3..e7174dfbf9 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -24,19 +24,11 @@ export class InspectTrafficPlugin extends DashboardPlugin { } public initializeBody (): JQuery { - return $('
    ') - .attr('id', '-blink-dev-tools') - .addClass('undocked') - .add( - $('') - .attr('type', 'module') - .attr('src', 'root.js') - ) + return $('') } public activated (): void { this.websocketApi.enableInspection(this.handleEvents.bind(this)) - Root.Runtime.startApplication('inspect_traffic') } public deactivated (): void { @@ -46,4 +38,13 @@ export class InspectTrafficPlugin extends DashboardPlugin { public handleEvents (message: Record): void { console.log(message) } + + private getDevtoolsIFrame (): JQuery { + return $('') + .attr('height', '80%') + .attr('width', '100%') + .attr('frameBorder', '0') + .attr('scrolling', 'no') + .attr('src', 'devtools/inspect_traffic.html') + } } diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css index e00498ccbc..b96ca241b6 100644 --- a/dashboard/src/proxy.css +++ b/dashboard/src/proxy.css @@ -11,26 +11,27 @@ background-color: #eeeeee; height: 100%; } + #proxyDashboard .remove-api-spec { top: 10px; right: 15px; } -.api-path-spec .list-group-item { - padding-left: 50px; -} - .app-header { padding-top: 10px; padding-bottom: 5px; } -.app-body .list-group { - margin-left: 15px; - margin-right: 15px; - margin-bottom: 10px; +.app-body { + padding-left: 15px; + padding-right: 15px; + padding-bottom: 50px; } .proxy-data { display: none; } + +.api-path-spec .list-group-item { + padding-left: 50px; +} \ No newline at end of file From 7561967be4ec69f0c456a248d59f7282d62fdf5b Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 12 Nov 2019 20:04:46 -0800 Subject: [PATCH 034/107] Load devtools within iframe (#171) --- dashboard/rollup.config.js | 8 +++++++- dashboard/src/core/devtools.ts | 2 +- .../src/core/plugins/inspect_traffic.html | 18 ++++++++++++++++++ dashboard/src/core/plugins/inspect_traffic.js | 10 ++++++++++ dashboard/src/core/plugins/inspect_traffic.ts | 19 ++++++++++--------- dashboard/src/proxy.css | 17 +++++++++-------- 6 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 dashboard/src/core/plugins/inspect_traffic.html create mode 100644 dashboard/src/core/plugins/inspect_traffic.js diff --git a/dashboard/rollup.config.js b/dashboard/rollup.config.js index 46683ae156..45160fe9ef 100644 --- a/dashboard/rollup.config.js +++ b/dashboard/rollup.config.js @@ -23,7 +23,13 @@ export const plugins = [ dest: '../public/dashboard', }, { src: 'src/core/plugins/inspect_traffic.json', - dest: '../public/dashboard' + dest: '../public/dashboard/devtools' + }, { + src: 'src/core/plugins/inspect_traffic.js', + dest: '../public/dashboard/devtools' + }, { + src: 'src/core/plugins/inspect_traffic.html', + dest: '../public/dashboard/devtools' }], }), /* obfuscatorPlugin({ diff --git a/dashboard/src/core/devtools.ts b/dashboard/src/core/devtools.ts index f597f6ab67..f231123781 100644 --- a/dashboard/src/core/devtools.ts +++ b/dashboard/src/core/devtools.ts @@ -14,7 +14,7 @@ const ncp = require('ncp').ncp ncp.limit = 16 function setUpDevTools () { - const destinationFolderPath = path.join(path.dirname(path.dirname(__dirname)), 'public', 'dashboard') + const destinationFolderPath = path.join(path.dirname(path.dirname(__dirname)), 'public', 'dashboard', 'devtools') const destinationFolderExists = fs.existsSync(destinationFolderPath) if (!destinationFolderExists) { diff --git a/dashboard/src/core/plugins/inspect_traffic.html b/dashboard/src/core/plugins/inspect_traffic.html new file mode 100644 index 0000000000..18e8689265 --- /dev/null +++ b/dashboard/src/core/plugins/inspect_traffic.html @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/dashboard/src/core/plugins/inspect_traffic.js b/dashboard/src/core/plugins/inspect_traffic.js new file mode 100644 index 0000000000..0e252ce6e2 --- /dev/null +++ b/dashboard/src/core/plugins/inspect_traffic.js @@ -0,0 +1,10 @@ +/* + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +*/ +Root.Runtime.startApplication('inspect_traffic'); diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index c537b071c3..e7174dfbf9 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -24,19 +24,11 @@ export class InspectTrafficPlugin extends DashboardPlugin { } public initializeBody (): JQuery { - return $('
    ') - .attr('id', '-blink-dev-tools') - .addClass('undocked') - .add( - $('') - .attr('type', 'module') - .attr('src', 'root.js') - ) + return $('') } public activated (): void { this.websocketApi.enableInspection(this.handleEvents.bind(this)) - Root.Runtime.startApplication('inspect_traffic') } public deactivated (): void { @@ -46,4 +38,13 @@ export class InspectTrafficPlugin extends DashboardPlugin { public handleEvents (message: Record): void { console.log(message) } + + private getDevtoolsIFrame (): JQuery { + return $('') + .attr('height', '80%') + .attr('width', '100%') + .attr('frameBorder', '0') + .attr('scrolling', 'no') + .attr('src', 'devtools/inspect_traffic.html') + } } diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css index e00498ccbc..b96ca241b6 100644 --- a/dashboard/src/proxy.css +++ b/dashboard/src/proxy.css @@ -11,26 +11,27 @@ background-color: #eeeeee; height: 100%; } + #proxyDashboard .remove-api-spec { top: 10px; right: 15px; } -.api-path-spec .list-group-item { - padding-left: 50px; -} - .app-header { padding-top: 10px; padding-bottom: 5px; } -.app-body .list-group { - margin-left: 15px; - margin-right: 15px; - margin-bottom: 10px; +.app-body { + padding-left: 15px; + padding-right: 15px; + padding-bottom: 50px; } .proxy-data { display: none; } + +.api-path-spec .list-group-item { + padding-left: 50px; +} \ No newline at end of file From c943dd7e27eaed3a72c144e9350cef6e1305fcfc Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 13 Nov 2019 16:38:59 -0800 Subject: [PATCH 035/107] Allow to pass flags as kwargs too in embed mode (#172) * Dynamically load devtools instead of on page load * Add support for passing flags as kwargs to main / start methods. * Fix tests for refactored code * Allow proxy.main, proxy.start, proxy.TestCase. Also update README.md to reflect the same. * Use Any for **opts * Move main as __init__ to avoid name conflicts * Fix tests * Update setup.py entry_point * Explicitly install requirements before setup.py * Explicitly mention packages of interest * ipv6 fails on ubuntu, use ipv4 * Make typing-extensions optional * Instead of putting it all under __init__.py, move main.py to proxy.py * Simply make setup.py module free * autopep8 --- README.md | 65 +- benchmark/benchmark.py | 4 +- dashboard/__init__.py | 1 - dashboard/dashboard.py | 3 - dashboard/setup.py | 12 +- dashboard/src/core/plugins/inspect_traffic.ts | 18 + plugin_examples/setup.py | 9 +- proxy/__init__.py | 14 + proxy/__main__.py | 2 +- proxy/common/constants.py | 7 - proxy/common/flags.py | 626 ++++++++++++------ proxy/common/types.py | 2 +- proxy/main.py | 250 ------- proxy/proxy.py | 94 +++ setup.py | 23 +- tests/http/test_protocol_handler.py | 9 +- tests/http/test_web_server.py | 11 +- tests/test_embed.py | 2 +- tests/test_main.py | 112 ++-- tests/test_set_open_file_limit.py | 8 +- 20 files changed, 677 insertions(+), 595 deletions(-) mode change 100644 => 100755 proxy/__init__.py delete mode 100755 proxy/main.py create mode 100644 proxy/proxy.py diff --git a/README.md b/README.md index 7db4870263..55399595c0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Table of Contents * [Blocking Mode](#blocking-mode) * [Non-blocking Mode](#non-blocking-mode) * [Unit testing with proxy.py](#unit-testing-with-proxypy) - * [proxy.main.TestCase](#proxymaintestcase) + * [proxy.TestCase](#proxytestcase) * [Override Startup Flags](#override-startup-flags) * [With unittest.TestCase](#with-unittesttestcase) * [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) @@ -723,27 +723,41 @@ Embed proxy.py ## Blocking Mode -Start `proxy.py` in embedded mode by using `main` method: +Start `proxy.py` in embedded mode with default configuration +by using the `main` method. Example: ``` -from proxy.main import main +import proxy if __name__ == '__main__': - main([]) + proxy.main() ``` -or, to start with arguments: +Customize startup flags by passing list of input arguments: ``` -from proxy.main import main +import proxy if __name__ == '__main__': - main([ + proxy.main([ '--hostname', '::1', '--port', '8899' ]) ``` +or, customize startup flags by passing them as kwargs: + +``` +import ipaddress +import proxy + +if __name__ == '__main__': + proxy.main( + hostname=ipaddress.IPv6Address('::1'), + port=8899 + ) +``` + Note that: 1. Calling `main` is simply equivalent to starting `proxy.py` from command line. @@ -751,36 +765,42 @@ Note that: ## Non-blocking Mode -Start `proxy.py` in embedded mode by using `start` method: +Start `proxy.py` in non-blocking embedded mode with default configuration +by using `start` method: Example: ``` -from proxy.main import start +import proxy if __name__ == '__main__': - with start([]): + with proxy.start([]): # ... your logic here ... ``` Note that: -1. `start` is simply a context manager. -2. Is similar to calling `main` except `start` won't block. -3. It automatically shut down `proxy.py`. +1. `start` is similar to `main`, except `start` won't block. +1. `start` is a context manager. + It will start `proxy.py` when called and will shut it down + once scope ends. +3. Just like `main`, startup flags with `start` method too + can be customized by either passing flags as list of + input arguments `start(['--port', '8899'])` or by using passing + flags as kwargs `start(port=8899)`. Unit testing with proxy.py ========================== -## proxy.main.TestCase +## proxy.TestCase To setup and teardown `proxy.py` for your Python unittest classes, -simply use `proxy.main.TestCase` instead of `unittest.TestCase`. +simply use `proxy.TestCase` instead of `unittest.TestCase`. Example: ``` -from proxy.main import TestCase +import proxy -class TestProxyPyEmbedded(TestCase): +class TestProxyPyEmbedded(proxy.TestCase): def test_my_application_with_proxy(self) -> None: self.assertTrue(True) @@ -788,10 +808,12 @@ class TestProxyPyEmbedded(TestCase): Note that: -1. `proxy.main.TestCase` overrides `unittest.TestCase.run()` method to setup and teardown `proxy.py`. +1. `proxy.TestCase` overrides `unittest.TestCase.run()` method to setup and teardown `proxy.py`. 2. `proxy.py` server will listen on a random available port on the system. This random port is available as `self.proxy_port` within your test cases. 3. Only a single worker is started by default (`--num-workers 1`) for faster setup and teardown. +4. Most importantly, `proxy.TestCase` also ensures `proxy.py` server + is up and running before proceeding with execution of tests. ## Override startup flags @@ -815,14 +837,13 @@ for full working example. ## With unittest.TestCase -If for some reasons you are unable to directly use `proxy.main.TestCase`, +If for some reasons you are unable to directly use `proxy.TestCase`, then simply override `unittest.TestCase.run` yourself to setup and teardown `proxy.py`. Example: ``` import unittest - -from proxy.main import start +import proxy class TestProxyPyEmbedded(unittest.TestCase): @@ -831,7 +852,7 @@ class TestProxyPyEmbedded(unittest.TestCase): self.assertTrue(True) def run(self, result: Optional[unittest.TestResult] = None) -> Any: - with start([ + with proxy.start([ '--num-workers', '1', '--port', '... random port ...']): super().run(result) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 1a75fd83fa..4ee78cf5b5 100755 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -15,11 +15,13 @@ import time from typing import List, Tuple -from proxy.common.constants import __homepage__, DEFAULT_BUFFER_SIZE +from proxy.common.constants import DEFAULT_BUFFER_SIZE from proxy.common.utils import build_http_request from proxy.http.methods import httpMethods from proxy.http.parser import httpParserStates, httpParserTypes, HttpParser +__homepage__ = 'https://github.com/abhinavsingh/proxy.py' + DEFAULT_N = 1 diff --git a/dashboard/__init__.py b/dashboard/__init__.py index f70d5120a3..87af91833d 100644 --- a/dashboard/__init__.py +++ b/dashboard/__init__.py @@ -8,4 +8,3 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from .dashboard import __version__ diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index b9e0fd7d8c..378c88a73e 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -24,9 +24,6 @@ from proxy.common.types import DictQueueType from proxy.core.connection import TcpClientConnection -VERSION = (0, 1, 0) -__version__ = '.'.join(map(str, VERSION[0:3])) - logger = logging.getLogger(__name__) diff --git a/dashboard/setup.py b/dashboard/setup.py index f0413a63ff..f88e0f612b 100644 --- a/dashboard/setup.py +++ b/dashboard/setup.py @@ -10,10 +10,14 @@ """ from setuptools import setup, find_packages -from proxy.common.constants import __author__, __author_email__ -from proxy.common.constants import __homepage__, __description__, __download_url__, __license__ - -from dashboard import __version__ +VERSION = (0, 1, 0) +__version__ = '.'.join(map(str, VERSION[0:3])) +__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' +__author__ = 'Abhinav Singh' +__author_email__ = 'mailsforabhinav@gmail.com' +__homepage__ = 'https://github.com/abhinavsingh/proxy.py' +__download_url__ = '%s/archive/master.zip' % __homepage__ +__license__ = 'BSD' setup( name='proxy.py-dashboard', diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index e7174dfbf9..6f25d9e978 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -29,6 +29,7 @@ export class InspectTrafficPlugin extends DashboardPlugin { public activated (): void { this.websocketApi.enableInspection(this.handleEvents.bind(this)) + this.ensureIFrame() } public deactivated (): void { @@ -39,10 +40,27 @@ export class InspectTrafficPlugin extends DashboardPlugin { console.log(message) } + private ensureIFrame (): void { + if ($('#' + this.getDevtoolsIFrameID()).length === 0) { + $('#' + this.name) + .children('.app-body') + .append( + this.getDevtoolsIFrame() + ) + } + } + + private getDevtoolsIFrameID (): string { + return this.name.concat('_inspector') + } + private getDevtoolsIFrame (): JQuery { return $('') + .attr('id', this.getDevtoolsIFrameID()) .attr('height', '80%') .attr('width', '100%') + .attr('padding', '0') + .attr('margin', '0') .attr('frameBorder', '0') .attr('scrolling', 'no') .attr('src', 'devtools/inspect_traffic.html') diff --git a/plugin_examples/setup.py b/plugin_examples/setup.py index 18899f2109..5e23a56e20 100644 --- a/plugin_examples/setup.py +++ b/plugin_examples/setup.py @@ -10,11 +10,14 @@ """ from setuptools import setup, find_packages -from proxy.common.constants import __author__, __author_email__ -from proxy.common.constants import __homepage__, __description__, __download_url__, __license__ - VERSION = (0, 1, 0) __version__ = '.'.join(map(str, VERSION[0:3])) +__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' +__author__ = 'Abhinav Singh' +__author_email__ = 'mailsforabhinav@gmail.com' +__homepage__ = 'https://github.com/abhinavsingh/proxy.py' +__download_url__ = '%s/archive/master.zip' % __homepage__ +__license__ = 'BSD' setup( name='proxy.py-plugins', diff --git a/proxy/__init__.py b/proxy/__init__.py old mode 100644 new mode 100755 index ba034136b9..e496b84569 --- a/proxy/__init__.py +++ b/proxy/__init__.py @@ -7,3 +7,17 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +from .proxy import entry_point +from .proxy import main, start +from .proxy import TestCase + +__all__ = [ + # PyPi package entry_point. See + # https://github.com/abhinavsingh/proxy.py#from-command-line-when-installed-using-pip + 'entry_point', + # Embed mode. See https://github.com/abhinavsingh/proxy.py#embed-proxypy + 'main', 'start', + # Unit testing with proxy.py. See + # https://github.com/abhinavsingh/proxy.py#unit-testing-with-proxypy + 'TestCase' +] diff --git a/proxy/__main__.py b/proxy/__main__.py index 50e913d7d2..1970354e48 100644 --- a/proxy/__main__.py +++ b/proxy/__main__.py @@ -7,7 +7,7 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from .main import entry_point +from .proxy import entry_point if __name__ == '__main__': entry_point() diff --git a/proxy/common/constants.py b/proxy/common/constants.py index fad7b97b9d..468b11945d 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -15,13 +15,6 @@ from .version import __version__ -__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' -__author__ = 'Abhinav Singh' -__author_email__ = 'mailsforabhinav@gmail.com' -__homepage__ = 'https://github.com/abhinavsingh/proxy.py' -__download_url__ = '%s/archive/master.zip' % __homepage__ -__license__ = 'BSD' - PROXY_PY_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) PROXY_PY_START_TIME = time.time() diff --git a/proxy/common/flags.py b/proxy/common/flags.py index a49ba3915f..efd5c58fdd 100644 --- a/proxy/common/flags.py +++ b/proxy/common/flags.py @@ -7,16 +7,21 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import logging +import importlib import argparse +import base64 import ipaddress import os import socket import multiprocessing +import sys +import inspect import pathlib -from typing import Optional, Union, Dict, List +from typing import Optional, Union, Dict, List, TypeVar, Type, cast, Any -from .utils import text_ +from .utils import text_, bytes_ from .types import DictQueueType from .constants import DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_BACKLOG, DEFAULT_BASIC_AUTH from .constants import DEFAULT_TIMEOUT, DEFAULT_DEVTOOLS_WS_PATH, DEFAULT_DISABLE_HTTP_PROXY, DEFAULT_DISABLE_HEADERS @@ -26,212 +31,17 @@ from .constants import DEFAULT_PAC_FILE_URL_PATH, DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT from .constants import DEFAULT_NUM_WORKERS, DEFAULT_VERSION, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME from .constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE, DEFAULT_STATIC_SERVER_DIR -from .constants import COMMA -from .constants import __homepage__ +from .constants import COMMA, DOT from .version import __version__ +__homepage__ = 'https://github.com/abhinavsingh/proxy.py' -def init_parser() -> argparse.ArgumentParser: - """Initializes and returns argument parser.""" - parser = argparse.ArgumentParser( - description='proxy.py v%s' % __version__, - epilog='Proxy.py not working? Report at: %s/issues/new' % __homepage__ - ) - # Argument names are ordered alphabetically. - parser.add_argument( - '--backlog', - type=int, - default=DEFAULT_BACKLOG, - help='Default: 100. Maximum number of pending connections to proxy server') - parser.add_argument( - '--basic-auth', - type=str, - default=DEFAULT_BASIC_AUTH, - help='Default: No authentication. Specify colon separated user:password ' - 'to enable basic authentication.') - parser.add_argument( - '--ca-key-file', - type=str, - default=DEFAULT_CA_KEY_FILE, - help='Default: None. CA key to use for signing dynamically generated ' - 'HTTPS certificates. If used, must also pass --ca-cert-file and --ca-signing-key-file' - ) - parser.add_argument( - '--ca-cert-dir', - type=str, - default=DEFAULT_CA_CERT_DIR, - help='Default: ~/.proxy.py. Directory to store dynamically generated certificates. ' - 'Also see --ca-key-file, --ca-cert-file and --ca-signing-key-file' - ) - parser.add_argument( - '--ca-cert-file', - type=str, - default=DEFAULT_CA_CERT_FILE, - help='Default: None. Signing certificate to use for signing dynamically generated ' - 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file' - ) - parser.add_argument( - '--ca-signing-key-file', - type=str, - default=DEFAULT_CA_SIGNING_KEY_FILE, - help='Default: None. CA signing key to use for dynamic generation of ' - 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-cert-file' - ) - parser.add_argument( - '--cert-file', - type=str, - default=DEFAULT_CERT_FILE, - help='Default: None. Server certificate to enable end-to-end TLS encryption with clients. ' - 'If used, must also pass --key-file.' - ) - parser.add_argument( - '--client-recvbuf-size', - type=int, - default=DEFAULT_CLIENT_RECVBUF_SIZE, - help='Default: 1 MB. Maximum amount of data received from the ' - 'client in a single recv() operation. Bump this ' - 'value for faster uploads at the expense of ' - 'increased RAM.') - parser.add_argument( - '--devtools-ws-path', - type=str, - default=DEFAULT_DEVTOOLS_WS_PATH, - help='Default: /devtools. Only applicable ' - 'if --enable-devtools is used.' - ) - parser.add_argument( - '--disable-headers', - type=str, - default=COMMA.join(DEFAULT_DISABLE_HEADERS), - help='Default: None. Comma separated list of headers to remove before ' - 'dispatching client request to upstream server.') - parser.add_argument( - '--disable-http-proxy', - action='store_true', - default=DEFAULT_DISABLE_HTTP_PROXY, - help='Default: False. Whether to disable proxy.HttpProxyPlugin.') - parser.add_argument( - '--enable-devtools', - action='store_true', - default=DEFAULT_ENABLE_DEVTOOLS, - help='Default: False. Enables integration with Chrome Devtool Frontend.' - ) - parser.add_argument( - '--enable-events', - action='store_true', - default=DEFAULT_ENABLE_EVENTS, - help='Default: False. Enables core to dispatch lifecycle events. ' - 'Plugins can be used to subscribe for core events.' - ) - parser.add_argument( - '--enable-static-server', - action='store_true', - default=DEFAULT_ENABLE_STATIC_SERVER, - help='Default: False. Enable inbuilt static file server. ' - 'Optionally, also use --static-server-dir to serve static content ' - 'from custom directory. By default, static file server serves ' - 'from public folder.' - ) - parser.add_argument( - '--enable-web-server', - action='store_true', - default=DEFAULT_ENABLE_WEB_SERVER, - help='Default: False. Whether to enable proxy.HttpWebServerPlugin.') - parser.add_argument( - '--hostname', - type=str, - default=str(DEFAULT_IPV6_HOSTNAME), - help='Default: ::1. Server IP address.') - parser.add_argument( - '--key-file', - type=str, - default=DEFAULT_KEY_FILE, - help='Default: None. Server key file to enable end-to-end TLS encryption with clients. ' - 'If used, must also pass --cert-file.' - ) - parser.add_argument( - '--log-level', - type=str, - default=DEFAULT_LOG_LEVEL, - help='Valid options: DEBUG, INFO (default), WARNING, ERROR, CRITICAL. ' - 'Both upper and lowercase values are allowed. ' - 'You may also simply use the leading character e.g. --log-level d') - parser.add_argument('--log-file', type=str, default=DEFAULT_LOG_FILE, - help='Default: sys.stdout. Log file destination.') - parser.add_argument('--log-format', type=str, default=DEFAULT_LOG_FORMAT, - help='Log format for Python logger.') - parser.add_argument('--num-workers', type=int, default=DEFAULT_NUM_WORKERS, - help='Defaults to number of CPU cores.') - parser.add_argument( - '--open-file-limit', - type=int, - default=DEFAULT_OPEN_FILE_LIMIT, - help='Default: 1024. Maximum number of files (TCP connections) ' - 'that proxy.py can open concurrently.') - parser.add_argument( - '--pac-file', - type=str, - default=DEFAULT_PAC_FILE, - help='A file (Proxy Auto Configuration) or string to serve when ' - 'the server receives a direct file request. ' - 'Using this option enables proxy.HttpWebServerPlugin.') - parser.add_argument( - '--pac-file-url-path', - type=str, - default=text_(DEFAULT_PAC_FILE_URL_PATH), - help='Default: %s. Web server path to serve the PAC file.' % - text_(DEFAULT_PAC_FILE_URL_PATH)) - parser.add_argument( - '--pid-file', - type=str, - default=DEFAULT_PID_FILE, - help='Default: None. Save parent process ID to a file.') - parser.add_argument( - '--plugins', - type=str, - default=DEFAULT_PLUGINS, - help='Comma separated plugins') - parser.add_argument('--port', type=int, default=DEFAULT_PORT, - help='Default: 8899. Server port.') - parser.add_argument( - '--server-recvbuf-size', - type=int, - default=DEFAULT_SERVER_RECVBUF_SIZE, - help='Default: 1 MB. Maximum amount of data received from the ' - 'server in a single recv() operation. Bump this ' - 'value for faster downloads at the expense of ' - 'increased RAM.') - parser.add_argument( - '--static-server-dir', - type=str, - default=DEFAULT_STATIC_SERVER_DIR, - help='Default: "public" folder in directory where proxy.py is placed. ' - 'This option is only applicable when static server is also enabled. ' - 'See --enable-static-server.' - ) - parser.add_argument( - '--threadless', - action='store_true', - default=DEFAULT_THREADLESS, - help='Default: False. When disabled a new thread is spawned ' - 'to handle each client connection.' - ) - parser.add_argument( - '--timeout', - type=int, - default=DEFAULT_TIMEOUT, - help='Default: ' + str(DEFAULT_TIMEOUT) + - '. Number of seconds after which ' - 'an inactive connection must be dropped. Inactivity is defined by no ' - 'data sent or received by the client.' - ) - parser.add_argument( - '--version', - '-v', - action='store_true', - default=DEFAULT_VERSION, - help='Prints proxy.py version.') - return parser +if os.name != 'nt': + import resource + +logger = logging.getLogger(__name__) + +T = TypeVar('T', bound='Flags') class Flags: @@ -266,7 +76,9 @@ def __init__( devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH, timeout: int = DEFAULT_TIMEOUT, threadless: bool = DEFAULT_THREADLESS, - enable_events: bool = DEFAULT_ENABLE_EVENTS) -> None: + enable_events: bool = DEFAULT_ENABLE_EVENTS, + pid_file: Optional[str] = DEFAULT_PID_FILE) -> None: + self.pid_file = pid_file self.threadless = threadless self.timeout = timeout self.auth_code = auth_code @@ -310,6 +122,137 @@ def __init__( self.proxy_py_data_dir, self.GENERATED_CERTS_DIR_NAME) os.makedirs(self.ca_cert_dir, exist_ok=True) + @classmethod + def initialize( + cls: Type[T], + input_args: Optional[List[str]], + **opts: Any) -> T: + if not Flags.is_py3(): + print( + 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' + 'If for some reasons you cannot upgrade, consider using "master" branch or simply ' + '"pip install proxy.py==0.3".' + '\n\n' + 'DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. ' + 'Please upgrade your Python as Python 2.7 won\'t be maintained after that date. ' + 'A future version of pip will drop support for Python 2.7.') + sys.exit(1) + + args = Flags.init_parser().parse_args(input_args) + + if args.version: + print(__version__) + sys.exit(0) + + if (args.cert_file and args.key_file) and \ + (args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file): + print('You can either enable end-to-end encryption OR TLS interception,' + 'not both together.') + sys.exit(1) + + auth_code = None + if args.basic_auth: + auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth)) + + Flags.setup_logger(args.log_file, args.log_level, args.log_format) + Flags.set_open_file_limit(args.open_file_limit) + + default_plugins = '' + devtools_event_queue: Optional[DictQueueType] = None + if args.enable_devtools: + default_plugins += 'proxy.http.devtools.DevtoolsProtocolPlugin,' + default_plugins += 'proxy.http.server.HttpWebServerPlugin,' + if not args.disable_http_proxy: + default_plugins += 'proxy.http.proxy.HttpProxyPlugin,' + if args.enable_web_server or \ + args.pac_file is not None or \ + args.enable_static_server: + if 'proxy.http.server.HttpWebServerPlugin' not in default_plugins: + default_plugins += 'proxy.http.server.HttpWebServerPlugin,' + if args.enable_devtools: + default_plugins += 'proxy.http.devtools.DevtoolsWebsocketPlugin,' + devtools_event_queue = multiprocessing.Manager().Queue() + if args.pac_file is not None: + default_plugins += 'proxy.http.server.HttpWebServerPacFilePlugin,' + + return cls( + auth_code=cast(Optional[bytes], opts.get('auth_code', auth_code)), + server_recvbuf_size=cast( + int, + opts.get( + 'server_recvbuf_size', + args.server_recvbuf_size)), + client_recvbuf_size=cast( + int, + opts.get( + 'client_recvbuf_size', + args.client_recvbuf_size)), + pac_file=cast( + Optional[str], opts.get( + 'pac_file', bytes_( + args.pac_file))), + pac_file_url_path=cast( + Optional[bytes], opts.get( + 'pac_file_url_path', bytes_( + args.pac_file_url_path))), + disable_headers=cast(Optional[List[bytes]], opts.get('disable_headers', [ + header.lower() for header in bytes_( + args.disable_headers).split(COMMA) if header.strip() != b''])), + certfile=cast( + Optional[str], opts.get( + 'cert_file', args.cert_file)), + keyfile=cast(Optional[str], opts.get('key_file', args.key_file)), + ca_cert_dir=cast( + Optional[str], opts.get( + 'ca_cert_dir', args.ca_cert_dir)), + ca_key_file=cast( + Optional[str], opts.get( + 'ca_key_file', args.ca_key_file)), + ca_cert_file=cast( + Optional[str], opts.get( + 'ca_cert_file', args.ca_cert_file)), + ca_signing_key_file=cast( + Optional[str], + opts.get( + 'ca_signing_key_file', + args.ca_signing_key_file)), + hostname=cast(Union[ipaddress.IPv4Address, + ipaddress.IPv6Address], + opts.get('hostname', ipaddress.ip_address(args.hostname))), + port=cast(int, opts.get('port', args.port)), + backlog=cast(int, opts.get('backlog', args.backlog)), + num_workers=cast(int, opts.get('num_workers', args.num_workers)), + static_server_dir=cast( + str, + opts.get( + 'static_server_dir', + args.static_server_dir)), + enable_static_server=cast( + bool, + opts.get( + 'enable_static_server', + args.enable_static_server)), + devtools_ws_path=cast( + bytes, + opts.get( + 'devtools_ws_path', + args.devtools_ws_path)), + timeout=cast(int, opts.get('timeout', args.timeout)), + threadless=cast(bool, opts.get('threadless', args.threadless)), + enable_events=cast( + bool, + opts.get( + 'enable_events', + args.enable_events)), + devtools_event_queue=cast( + Optional[DictQueueType], opts.get( + 'devtools_event_queue', devtools_event_queue)), + plugins=Flags.load_plugins( + bytes_( + '%s%s' % + (default_plugins, opts.get('plugins', args.plugins)))), + pid_file=cast(Optional[str], opts.get('pid_file', args.pid_file))) + def tls_interception_enabled(self) -> bool: return self.ca_key_file is not None and \ self.ca_cert_dir is not None and \ @@ -319,3 +262,274 @@ def tls_interception_enabled(self) -> bool: def encryption_enabled(self) -> bool: return self.keyfile is not None and \ self.certfile is not None + + @staticmethod + def init_parser() -> argparse.ArgumentParser: + """Initializes and returns argument parser.""" + parser = argparse.ArgumentParser( + description='proxy.py v%s' % __version__, + epilog='Proxy.py not working? Report at: %s/issues/new' % __homepage__ + ) + # Argument names are ordered alphabetically. + parser.add_argument( + '--backlog', + type=int, + default=DEFAULT_BACKLOG, + help='Default: 100. Maximum number of pending connections to proxy server') + parser.add_argument( + '--basic-auth', + type=str, + default=DEFAULT_BASIC_AUTH, + help='Default: No authentication. Specify colon separated user:password ' + 'to enable basic authentication.') + parser.add_argument( + '--ca-key-file', + type=str, + default=DEFAULT_CA_KEY_FILE, + help='Default: None. CA key to use for signing dynamically generated ' + 'HTTPS certificates. If used, must also pass --ca-cert-file and --ca-signing-key-file' + ) + parser.add_argument( + '--ca-cert-dir', + type=str, + default=DEFAULT_CA_CERT_DIR, + help='Default: ~/.proxy.py. Directory to store dynamically generated certificates. ' + 'Also see --ca-key-file, --ca-cert-file and --ca-signing-key-file' + ) + parser.add_argument( + '--ca-cert-file', + type=str, + default=DEFAULT_CA_CERT_FILE, + help='Default: None. Signing certificate to use for signing dynamically generated ' + 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file' + ) + parser.add_argument( + '--ca-signing-key-file', + type=str, + default=DEFAULT_CA_SIGNING_KEY_FILE, + help='Default: None. CA signing key to use for dynamic generation of ' + 'HTTPS certificates. If used, must also pass --ca-key-file and --ca-cert-file' + ) + parser.add_argument( + '--cert-file', + type=str, + default=DEFAULT_CERT_FILE, + help='Default: None. Server certificate to enable end-to-end TLS encryption with clients. ' + 'If used, must also pass --key-file.' + ) + parser.add_argument( + '--client-recvbuf-size', + type=int, + default=DEFAULT_CLIENT_RECVBUF_SIZE, + help='Default: 1 MB. Maximum amount of data received from the ' + 'client in a single recv() operation. Bump this ' + 'value for faster uploads at the expense of ' + 'increased RAM.') + parser.add_argument( + '--devtools-ws-path', + type=str, + default=DEFAULT_DEVTOOLS_WS_PATH, + help='Default: /devtools. Only applicable ' + 'if --enable-devtools is used.' + ) + parser.add_argument( + '--disable-headers', + type=str, + default=COMMA.join(DEFAULT_DISABLE_HEADERS), + help='Default: None. Comma separated list of headers to remove before ' + 'dispatching client request to upstream server.') + parser.add_argument( + '--disable-http-proxy', + action='store_true', + default=DEFAULT_DISABLE_HTTP_PROXY, + help='Default: False. Whether to disable proxy.HttpProxyPlugin.') + parser.add_argument( + '--enable-devtools', + action='store_true', + default=DEFAULT_ENABLE_DEVTOOLS, + help='Default: False. Enables integration with Chrome Devtool Frontend.' + ) + parser.add_argument( + '--enable-events', + action='store_true', + default=DEFAULT_ENABLE_EVENTS, + help='Default: False. Enables core to dispatch lifecycle events. ' + 'Plugins can be used to subscribe for core events.' + ) + parser.add_argument( + '--enable-static-server', + action='store_true', + default=DEFAULT_ENABLE_STATIC_SERVER, + help='Default: False. Enable inbuilt static file server. ' + 'Optionally, also use --static-server-dir to serve static content ' + 'from custom directory. By default, static file server serves ' + 'from public folder.' + ) + parser.add_argument( + '--enable-web-server', + action='store_true', + default=DEFAULT_ENABLE_WEB_SERVER, + help='Default: False. Whether to enable proxy.HttpWebServerPlugin.') + parser.add_argument( + '--hostname', + type=str, + default=str(DEFAULT_IPV6_HOSTNAME), + help='Default: ::1. Server IP address.') + parser.add_argument( + '--key-file', + type=str, + default=DEFAULT_KEY_FILE, + help='Default: None. Server key file to enable end-to-end TLS encryption with clients. ' + 'If used, must also pass --cert-file.' + ) + parser.add_argument( + '--log-level', + type=str, + default=DEFAULT_LOG_LEVEL, + help='Valid options: DEBUG, INFO (default), WARNING, ERROR, CRITICAL. ' + 'Both upper and lowercase values are allowed. ' + 'You may also simply use the leading character e.g. --log-level d') + parser.add_argument('--log-file', type=str, default=DEFAULT_LOG_FILE, + help='Default: sys.stdout. Log file destination.') + parser.add_argument('--log-format', type=str, default=DEFAULT_LOG_FORMAT, + help='Log format for Python logger.') + parser.add_argument('--num-workers', type=int, default=DEFAULT_NUM_WORKERS, + help='Defaults to number of CPU cores.') + parser.add_argument( + '--open-file-limit', + type=int, + default=DEFAULT_OPEN_FILE_LIMIT, + help='Default: 1024. Maximum number of files (TCP connections) ' + 'that proxy.py can open concurrently.') + parser.add_argument( + '--pac-file', + type=str, + default=DEFAULT_PAC_FILE, + help='A file (Proxy Auto Configuration) or string to serve when ' + 'the server receives a direct file request. ' + 'Using this option enables proxy.HttpWebServerPlugin.') + parser.add_argument( + '--pac-file-url-path', + type=str, + default=text_(DEFAULT_PAC_FILE_URL_PATH), + help='Default: %s. Web server path to serve the PAC file.' % + text_(DEFAULT_PAC_FILE_URL_PATH)) + parser.add_argument( + '--pid-file', + type=str, + default=DEFAULT_PID_FILE, + help='Default: None. Save parent process ID to a file.') + parser.add_argument( + '--plugins', + type=str, + default=DEFAULT_PLUGINS, + help='Comma separated plugins') + parser.add_argument('--port', type=int, default=DEFAULT_PORT, + help='Default: 8899. Server port.') + parser.add_argument( + '--server-recvbuf-size', + type=int, + default=DEFAULT_SERVER_RECVBUF_SIZE, + help='Default: 1 MB. Maximum amount of data received from the ' + 'server in a single recv() operation. Bump this ' + 'value for faster downloads at the expense of ' + 'increased RAM.') + parser.add_argument( + '--static-server-dir', + type=str, + default=DEFAULT_STATIC_SERVER_DIR, + help='Default: "public" folder in directory where proxy.py is placed. ' + 'This option is only applicable when static server is also enabled. ' + 'See --enable-static-server.' + ) + parser.add_argument( + '--threadless', + action='store_true', + default=DEFAULT_THREADLESS, + help='Default: False. When disabled a new thread is spawned ' + 'to handle each client connection.' + ) + parser.add_argument( + '--timeout', + type=int, + default=DEFAULT_TIMEOUT, + help='Default: ' + str(DEFAULT_TIMEOUT) + + '. Number of seconds after which ' + 'an inactive connection must be dropped. Inactivity is defined by no ' + 'data sent or received by the client.' + ) + parser.add_argument( + '--version', + '-v', + action='store_true', + default=DEFAULT_VERSION, + help='Prints proxy.py version.') + return parser + + @staticmethod + def set_open_file_limit(soft_limit: int) -> None: + """Configure open file description soft limit on supported OS.""" + if os.name != 'nt': # resource module not available on Windows OS + curr_soft_limit, curr_hard_limit = resource.getrlimit( + resource.RLIMIT_NOFILE) + if curr_soft_limit < soft_limit < curr_hard_limit: + resource.setrlimit( + resource.RLIMIT_NOFILE, (soft_limit, curr_hard_limit)) + logger.debug( + 'Open file soft limit set to %d', soft_limit) + + @staticmethod + def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]: + """Accepts a comma separated list of Python modules and returns + a list of respective Python classes.""" + p: Dict[bytes, List[type]] = { + b'HttpProtocolHandlerPlugin': [], + b'HttpProxyBasePlugin': [], + b'HttpWebServerBasePlugin': [], + } + for plugin_ in plugins.split(COMMA): + plugin = text_(plugin_.strip()) + if plugin == '': + continue + module_name, klass_name = plugin.rsplit(text_(DOT), 1) + klass = getattr( + importlib.import_module( + module_name.replace( + os.path.sep, text_(DOT))), + klass_name) + base_klass = inspect.getmro(klass)[1] + p[bytes_(base_klass.__name__)].append(klass) + logger.info( + 'Loaded %s %s.%s', + 'plugin' if klass.__name__ != 'HttpWebServerRouteHandler' else 'route', + module_name, + # HttpWebServerRouteHandler route decorator adds a special + # staticmethod to return decorated function name + klass.__name__ if klass.__name__ != 'HttpWebServerRouteHandler' else klass.name()) + return p + + @staticmethod + def setup_logger( + log_file: Optional[str] = DEFAULT_LOG_FILE, + log_level: str = DEFAULT_LOG_LEVEL, + log_format: str = DEFAULT_LOG_FORMAT) -> None: + ll = getattr( + logging, + {'D': 'DEBUG', + 'I': 'INFO', + 'W': 'WARNING', + 'E': 'ERROR', + 'C': 'CRITICAL'}[log_level.upper()[0]]) + if log_file: + logging.basicConfig( + filename=log_file, + filemode='a', + level=ll, + format=log_format) + else: + logging.basicConfig(level=ll, format=log_format) + + @staticmethod + def is_py3() -> bool: + """Exists only to avoid mocking sys.version_info in tests.""" + return sys.version_info[0] == 3 diff --git a/proxy/common/types.py b/proxy/common/types.py index 514523af40..d749e9b8d9 100644 --- a/proxy/common/types.py +++ b/proxy/common/types.py @@ -10,8 +10,8 @@ import queue from typing import TYPE_CHECKING, Dict, Any -from typing_extensions import Protocol +from typing_extensions import Protocol if TYPE_CHECKING: DictQueueType = queue.Queue[Dict[str, Any]] # pragma: no cover diff --git a/proxy/main.py b/proxy/main.py deleted file mode 100755 index cd8e3f8356..0000000000 --- a/proxy/main.py +++ /dev/null @@ -1,250 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import base64 -import contextlib -import importlib -import inspect -import ipaddress -import logging -import multiprocessing -import os -import sys -import time -import unittest -from typing import Dict, List, Optional, Generator, Any - -from .common.flags import Flags, init_parser -from .common.utils import text_, bytes_, get_available_port, new_socket_connection -from .common.types import DictQueueType -from .common.constants import DOT, COMMA -from .common.constants import DEFAULT_LOG_FORMAT, DEFAULT_LOG_FILE, DEFAULT_LOG_LEVEL -from .common.version import __version__ -from .core.acceptor import AcceptorPool -from .http.handler import HttpProtocolHandler - -if os.name != 'nt': - import resource - -logger = logging.getLogger(__name__) - - -def is_py3() -> bool: - """Exists only to avoid mocking sys.version_info in tests.""" - return sys.version_info[0] == 3 - - -def set_open_file_limit(soft_limit: int) -> None: - """Configure open file description soft limit on supported OS.""" - if os.name != 'nt': # resource module not available on Windows OS - curr_soft_limit, curr_hard_limit = resource.getrlimit( - resource.RLIMIT_NOFILE) - if curr_soft_limit < soft_limit < curr_hard_limit: - resource.setrlimit( - resource.RLIMIT_NOFILE, (soft_limit, curr_hard_limit)) - logger.debug( - 'Open file soft limit set to %d', soft_limit) - - -def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]: - """Accepts a comma separated list of Python modules and returns - a list of respective Python classes.""" - p: Dict[bytes, List[type]] = { - b'HttpProtocolHandlerPlugin': [], - b'HttpProxyBasePlugin': [], - b'HttpWebServerBasePlugin': [], - } - for plugin_ in plugins.split(COMMA): - plugin = text_(plugin_.strip()) - if plugin == '': - continue - module_name, klass_name = plugin.rsplit(text_(DOT), 1) - klass = getattr( - importlib.import_module( - module_name.replace( - os.path.sep, text_(DOT))), - klass_name) - base_klass = inspect.getmro(klass)[1] - p[bytes_(base_klass.__name__)].append(klass) - logger.info( - 'Loaded %s %s.%s', - 'plugin' if klass.__name__ != 'HttpWebServerRouteHandler' else 'route', - module_name, - # HttpWebServerRouteHandler route decorator adds a special - # staticmethod to return decorated function name - klass.__name__ if klass.__name__ != 'HttpWebServerRouteHandler' else klass.name()) - return p - - -def setup_logger( - log_file: Optional[str] = DEFAULT_LOG_FILE, - log_level: str = DEFAULT_LOG_LEVEL, - log_format: str = DEFAULT_LOG_FORMAT) -> None: - ll = getattr( - logging, - {'D': 'DEBUG', - 'I': 'INFO', - 'W': 'WARNING', - 'E': 'ERROR', - 'C': 'CRITICAL'}[log_level.upper()[0]]) - if log_file: - logging.basicConfig( - filename=log_file, - filemode='a', - level=ll, - format=log_format) - else: - logging.basicConfig(level=ll, format=log_format) - - -@contextlib.contextmanager -def start(input_args: List[str]) -> Generator[None, None, None]: - if not is_py3(): - print( - 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' - 'If for some reasons you cannot upgrade, consider using "master" branch or simply ' - '"pip install proxy.py==0.3".' - '\n\n' - 'DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. ' - 'Please upgrade your Python as Python 2.7 won\'t be maintained after that date. ' - 'A future version of pip will drop support for Python 2.7.') - sys.exit(1) - args = init_parser().parse_args(input_args) - - if args.version: - print(__version__) - sys.exit(0) - - if (args.cert_file and args.key_file) and \ - (args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file): - print('You can either enable end-to-end encryption OR TLS interception,' - 'not both together.') - sys.exit(1) - - try: - setup_logger(args.log_file, args.log_level, args.log_format) - set_open_file_limit(args.open_file_limit) - - auth_code = None - if args.basic_auth: - auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth)) - - default_plugins = '' - devtools_event_queue: Optional[DictQueueType] = None - if args.enable_devtools: - default_plugins += 'proxy.http.devtools.DevtoolsProtocolPlugin,' - default_plugins += 'proxy.http.server.HttpWebServerPlugin,' - if not args.disable_http_proxy: - default_plugins += 'proxy.http.proxy.HttpProxyPlugin,' - if args.enable_web_server or \ - args.pac_file is not None or \ - args.enable_static_server: - if 'proxy.http.server.HttpWebServerPlugin' not in default_plugins: - default_plugins += 'proxy.http.server.HttpWebServerPlugin,' - if args.enable_devtools: - default_plugins += 'proxy.http.devtools.DevtoolsWebsocketPlugin,' - devtools_event_queue = multiprocessing.Manager().Queue() - if args.pac_file is not None: - default_plugins += 'proxy.http.server.HttpWebServerPacFilePlugin,' - - flags = Flags( - auth_code=auth_code, - server_recvbuf_size=args.server_recvbuf_size, - client_recvbuf_size=args.client_recvbuf_size, - pac_file=bytes_(args.pac_file), - pac_file_url_path=bytes_(args.pac_file_url_path), - disable_headers=[ - header.lower() for header in bytes_( - args.disable_headers).split(COMMA) if header.strip() != b''], - certfile=args.cert_file, - keyfile=args.key_file, - ca_cert_dir=args.ca_cert_dir, - ca_key_file=args.ca_key_file, - ca_cert_file=args.ca_cert_file, - ca_signing_key_file=args.ca_signing_key_file, - hostname=ipaddress.ip_address(args.hostname), - port=args.port, - backlog=args.backlog, - num_workers=args.num_workers, - static_server_dir=args.static_server_dir, - enable_static_server=args.enable_static_server, - devtools_event_queue=devtools_event_queue, - devtools_ws_path=args.devtools_ws_path, - timeout=args.timeout, - threadless=args.threadless, - enable_events=args.enable_events) - - flags.plugins = load_plugins( - bytes_( - '%s%s' % - (default_plugins, args.plugins))) - - acceptor_pool = AcceptorPool( - flags=flags, - work_klass=HttpProtocolHandler - ) - - if args.pid_file: - with open(args.pid_file, 'wb') as pid_file: - pid_file.write(bytes_(os.getpid())) - - try: - acceptor_pool.setup() - yield - except Exception as e: - logger.exception('exception', exc_info=e) - finally: - acceptor_pool.shutdown() - except KeyboardInterrupt: # pragma: no cover - pass - finally: - if args.pid_file and os.path.exists(args.pid_file): - os.remove(args.pid_file) - - -class TestCase(unittest.TestCase): - """TestCase class that automatically setup and teardown proxy.py.""" - - DEFAULT_PROXY_PY_STARTUP_FLAGS = [ - '--num-workers', '1', - ] - - def run(self, result: Optional[unittest.TestResult] = None) -> Any: - self.proxy_port = get_available_port() - - flags = getattr(self, 'PROXY_PY_STARTUP_FLAGS') \ - if hasattr(self, 'PROXY_PY_STARTUP_FLAGS') \ - else self.DEFAULT_PROXY_PY_STARTUP_FLAGS - flags.append('--port') - flags.append(str(self.proxy_port)) - - with start(flags): - # Wait for proxy.py server to come up - while True: - try: - conn = new_socket_connection( - ('localhost', self.proxy_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - finally: - conn.close() - # Run tests - super().run(result) - - -def main(input_args: List[str]) -> None: - with start(input_args): - # TODO: Introduce cron feature instead of mindless sleep - while True: - time.sleep(1) - - -def entry_point() -> None: - main(sys.argv[1:]) diff --git a/proxy/proxy.py b/proxy/proxy.py new file mode 100644 index 0000000000..4833790461 --- /dev/null +++ b/proxy/proxy.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import contextlib +import os +import sys +import time +import unittest +import logging + +from typing import List, Optional, Generator, Any + +from .common.utils import bytes_, get_available_port, new_socket_connection +from .common.flags import Flags +from .core.acceptor import AcceptorPool +from .http.handler import HttpProtocolHandler + +logger = logging.getLogger(__name__) + + +class TestCase(unittest.TestCase): + """Base TestCase class that automatically setup and teardown proxy.py.""" + + DEFAULT_PROXY_PY_STARTUP_FLAGS = [ + '--num-workers', '1', + ] + + def run(self, result: Optional[unittest.TestResult] = None) -> Any: + self.proxy_port = get_available_port() + + flags = getattr(self, 'PROXY_PY_STARTUP_FLAGS') \ + if hasattr(self, 'PROXY_PY_STARTUP_FLAGS') \ + else self.DEFAULT_PROXY_PY_STARTUP_FLAGS + flags.append('--port') + flags.append(str(self.proxy_port)) + + with start(flags): + # Wait for proxy.py server to come up + while True: + try: + conn = new_socket_connection( + ('localhost', self.proxy_port)) + conn.close() + break + except ConnectionRefusedError: + time.sleep(0.1) + # Run tests + super().run(result) + + +@contextlib.contextmanager +def start( + input_args: Optional[List[str]] = None, + **opts: Any) -> Generator[None, None, None]: + flags = Flags.initialize(input_args, **opts) + try: + acceptor_pool = AcceptorPool( + flags=flags, + work_klass=HttpProtocolHandler + ) + if flags.pid_file is not None: + with open(flags.pid_file, 'wb') as pid_file: + pid_file.write(bytes_(os.getpid())) + try: + acceptor_pool.setup() + yield + except Exception as e: + logger.exception('exception', exc_info=e) + finally: + acceptor_pool.shutdown() + except KeyboardInterrupt: # pragma: no cover + pass + finally: + if flags.pid_file and os.path.exists(flags.pid_file): + os.remove(flags.pid_file) + + +def main( + input_args: Optional[List[str]] = None, + **opts: Any) -> None: + with start(input_args=input_args, **opts): + # TODO: Introduce cron feature instead of mindless sleep + while True: + time.sleep(1) + + +def entry_point() -> None: + main(input_args=sys.argv[1:]) diff --git a/setup.py b/setup.py index dd48f2cef9..5adf6d6481 100644 --- a/setup.py +++ b/setup.py @@ -8,11 +8,16 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from setuptools import setup, find_packages +from setuptools import setup -from proxy.common.version import __version__ -from proxy.common.constants import __author__, __author_email__ -from proxy.common.constants import __homepage__, __description__, __download_url__, __license__ +VERSION = (2, 0, 0) +__version__ = '.'.join(map(str, VERSION[0:3])) +__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' +__author__ = 'Abhinav Singh' +__author_email__ = 'mailsforabhinav@gmail.com' +__homepage__ = 'https://github.com/abhinavsingh/proxy.py' +__download_url__ = '%s/archive/master.zip' % __homepage__ +__license__ = 'BSD' setup( name='proxy.py', @@ -25,17 +30,11 @@ long_description_content_type='text/markdown', download_url=__download_url__, license=__license__, - packages=find_packages( - exclude=[ - 'benchmark', - 'dashboard', - 'plugin_examples', - 'tests' - ]), + packages=['proxy', 'proxy.common', 'proxy.core', 'proxy.http'], install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ 'console_scripts': [ - 'proxy = proxy.main:entry_point' + 'proxy = proxy:entry_point' ] }, classifiers=[ diff --git a/tests/http/test_protocol_handler.py b/tests/http/test_protocol_handler.py index 37a62b233e..6dd1187ca0 100644 --- a/tests/http/test_protocol_handler.py +++ b/tests/http/test_protocol_handler.py @@ -22,7 +22,6 @@ from proxy.http.parser import httpParserStates, httpParserTypes from proxy.http.exception import ProxyAuthenticationFailed, ProxyConnectionFailed from proxy.http.handler import HttpProtocolHandler -from proxy.main import load_plugins from proxy.common.version import __version__ @@ -39,7 +38,7 @@ def setUp(self, self.http_server_port = 65535 self.flags = Flags() - self.flags.plugins = load_plugins( + self.flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.mock_selector = mock_selector @@ -172,7 +171,7 @@ def test_proxy_authentication_failed( flags = Flags( auth_code=b'Basic %s' % base64.b64encode(b'user:pass')) - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( self.fileno, self._addr, flags=flags) @@ -204,7 +203,7 @@ def test_authenticated_proxy_http_get( flags = Flags( auth_code=b'Basic %s' % base64.b64encode(b'user:pass')) - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( @@ -252,7 +251,7 @@ def test_authenticated_proxy_http_tunnel( flags = Flags( auth_code=b'Basic %s' % base64.b64encode(b'user:pass')) - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( diff --git a/tests/http/test_web_server.py b/tests/http/test_web_server.py index ad459dcda9..b0352d6180 100644 --- a/tests/http/test_web_server.py +++ b/tests/http/test_web_server.py @@ -13,7 +13,6 @@ import selectors from unittest import mock -from proxy.main import load_plugins from proxy.common.flags import Flags from proxy.http.handler import HttpProtocolHandler from proxy.http.parser import httpParserStates @@ -32,7 +31,7 @@ def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self._conn = mock_fromfd.return_value self.mock_selector = mock_selector self.flags = Flags() - self.flags.plugins = load_plugins( + self.flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( self.fileno, self._addr, flags=self.flags) @@ -92,7 +91,7 @@ def test_default_web_server_returns_404( events=selectors.EVENT_READ, data=None), selectors.EVENT_READ), ] flags = Flags() - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( self.fileno, self._addr, flags=flags) @@ -145,7 +144,7 @@ def test_static_web_server_serves( flags = Flags( enable_static_server=True, static_server_dir=static_server_dir) - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( @@ -189,7 +188,7 @@ def test_static_web_server_serves_404( data=None), selectors.EVENT_WRITE)], ] flags = Flags(enable_static_server=True) - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( @@ -223,7 +222,7 @@ def test_on_client_connection_called_on_teardown( def init_and_make_pac_file_request(self, pac_file: str) -> None: flags = Flags(pac_file=pac_file) - flags.plugins = load_plugins( + flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin,' b'proxy.http.server.HttpWebServerPacFilePlugin') self.protocol_handler = HttpProtocolHandler( diff --git a/tests/test_embed.py b/tests/test_embed.py index e0091a2adb..585153e374 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -7,11 +7,11 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +from proxy.proxy import TestCase from proxy.common.constants import DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE from proxy.common.utils import socket_connection, build_http_request, build_http_response from proxy.http.codes import httpStatusCodes from proxy.http.methods import httpMethods -from proxy.main import TestCase class TestProxyPyEmbedded(TestCase): diff --git a/tests/test_main.py b/tests/test_main.py index 6ebb13a11c..d4550267cd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -13,8 +13,10 @@ import os from unittest import mock +from typing import List -from proxy.main import main +from proxy.proxy import main +from proxy.common.flags import Flags from proxy.common.utils import bytes_ from proxy.http.handler import HttpProtocolHandler @@ -26,7 +28,6 @@ from proxy.common.constants import DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT from proxy.common.constants import DEFAULT_NUM_WORKERS, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME from proxy.common.constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE -from proxy.common.constants import COMMA from proxy.common.version import __version__ @@ -68,66 +69,29 @@ def mock_default_args(mock_args: mock.Mock) -> None: mock_args.enable_events = DEFAULT_ENABLE_EVENTS @mock.patch('time.sleep') - @mock.patch('proxy.main.load_plugins') - @mock.patch('proxy.main.init_parser') - @mock.patch('proxy.main.set_open_file_limit') - @mock.patch('proxy.main.Flags') - @mock.patch('proxy.main.AcceptorPool') + @mock.patch('proxy.proxy.Flags') + @mock.patch('proxy.proxy.AcceptorPool') @mock.patch('logging.basicConfig') def test_init_with_no_arguments( self, mock_logging_config: mock.Mock, mock_acceptor_pool: mock.Mock, - mock_protocol_config: mock.Mock, - mock_set_open_file_limit: mock.Mock, - mock_init_parser: mock.Mock, - mock_load_plugins: mock.Mock, + mock_flags: mock.Mock, mock_sleep: mock.Mock) -> None: mock_sleep.side_effect = KeyboardInterrupt() - mock_args = mock_init_parser.return_value.parse_args.return_value - self.mock_default_args(mock_args) - main([]) + input_args: List[str] = [] + flags = Flags.initialize(input_args=input_args) + mock_flags.initialize = lambda *args, **kwargs: flags - mock_init_parser.assert_called() - mock_init_parser.return_value.parse_args.called_with([]) + main() - mock_load_plugins.assert_called_with( - b'proxy.http.proxy.HttpProxyPlugin,') mock_logging_config.assert_called_with( level=logging.INFO, format=DEFAULT_LOG_FORMAT ) - mock_set_open_file_limit.assert_called_with(mock_args.open_file_limit) - mock_protocol_config.assert_called_with( - auth_code=mock_args.basic_auth, - backlog=mock_args.backlog, - ca_cert_dir=mock_args.ca_cert_dir, - ca_cert_file=mock_args.ca_cert_file, - ca_key_file=mock_args.ca_key_file, - ca_signing_key_file=mock_args.ca_signing_key_file, - certfile=mock_args.cert_file, - client_recvbuf_size=mock_args.client_recvbuf_size, - hostname=mock_args.hostname, - keyfile=mock_args.key_file, - num_workers=0, - pac_file=mock_args.pac_file, - pac_file_url_path=mock_args.pac_file_url_path, - port=mock_args.port, - server_recvbuf_size=mock_args.server_recvbuf_size, - disable_headers=[ - header.lower() for header in bytes_( - mock_args.disable_headers).split(COMMA) if header.strip() != b''], - static_server_dir=mock_args.static_server_dir, - enable_static_server=mock_args.enable_static_server, - devtools_event_queue=None, - devtools_ws_path=DEFAULT_DEVTOOLS_WS_PATH, - timeout=DEFAULT_TIMEOUT, - threadless=DEFAULT_THREADLESS, - enable_events=DEFAULT_ENABLE_EVENTS, - ) mock_acceptor_pool.assert_called_with( - flags=mock_protocol_config.return_value, + flags=flags, work_klass=HttpProtocolHandler, ) mock_acceptor_pool.return_value.setup.assert_called() @@ -138,8 +102,8 @@ def test_init_with_no_arguments( @mock.patch('os.remove') @mock.patch('os.path.exists') @mock.patch('builtins.open') - @mock.patch('proxy.main.init_parser') - @mock.patch('proxy.main.AcceptorPool') + @mock.patch('proxy.proxy.Flags.init_parser') + @mock.patch('proxy.proxy.AcceptorPool') def test_pid_file_is_written_and_removed( self, mock_acceptor_pool: mock.Mock, @@ -164,58 +128,61 @@ def test_pid_file_is_written_and_removed( mock_remove.assert_called_with(pid_file) @mock.patch('time.sleep') - @mock.patch('proxy.main.Flags') - @mock.patch('proxy.main.AcceptorPool') + @mock.patch('proxy.proxy.Flags') + @mock.patch('proxy.proxy.AcceptorPool') def test_basic_auth( self, mock_acceptor_pool: mock.Mock, - mock_protocol_config: mock.Mock, + mock_flags: mock.Mock, mock_sleep: mock.Mock) -> None: mock_sleep.side_effect = KeyboardInterrupt() - main(['--basic-auth', 'user:pass']) - flags = mock_protocol_config.return_value + + input_args = ['--basic-auth', 'user:pass'] + flags = Flags.initialize(input_args=input_args) + mock_flags.initialize = lambda *args, **kwargs: flags + + main(input_args=input_args) mock_acceptor_pool.assert_called_with( flags=flags, work_klass=HttpProtocolHandler) self.assertEqual( - mock_protocol_config.call_args[1]['auth_code'], + flags.auth_code, b'Basic dXNlcjpwYXNz') - @mock.patch('builtins.print') - def test_main_version( - self, - mock_print: mock.Mock) -> None: - with self.assertRaises(SystemExit): - main(['--version']) - mock_print.assert_called_with(__version__) - @mock.patch('time.sleep') @mock.patch('builtins.print') - @mock.patch('proxy.main.AcceptorPool') - @mock.patch('proxy.main.is_py3') + @mock.patch('proxy.proxy.Flags') + @mock.patch('proxy.proxy.AcceptorPool') + @mock.patch('proxy.proxy.Flags.is_py3') def test_main_py3_runs( self, mock_is_py3: mock.Mock, mock_acceptor_pool: mock.Mock, + mock_flags: mock.Mock, mock_print: mock.Mock, mock_sleep: mock.Mock) -> None: mock_sleep.side_effect = KeyboardInterrupt() + + input_args = ['--basic-auth', 'user:pass'] + flags = Flags.initialize(input_args=input_args) + mock_flags.initialize = lambda *args, **kwargs: flags + mock_is_py3.return_value = True - main([]) + main(num_workers=1) mock_is_py3.assert_called() mock_print.assert_not_called() mock_acceptor_pool.assert_called() mock_acceptor_pool.return_value.setup.assert_called() @mock.patch('builtins.print') - @mock.patch('proxy.main.is_py3') + @mock.patch('proxy.proxy.Flags.is_py3') def test_main_py2_exit( self, mock_is_py3: mock.Mock, mock_print: mock.Mock) -> None: mock_is_py3.return_value = False with self.assertRaises(SystemExit) as e: - main([]) + main(num_workers=1) mock_print.assert_called_with( 'DEPRECATION: "develop" branch no longer supports Python 2.7. Kindly upgrade to Python 3+. ' 'If for some reasons you cannot upgrade, consider using "master" branch or simply ' @@ -227,3 +194,12 @@ def test_main_py2_exit( ) self.assertEqual(e.exception.code, 1) mock_is_py3.assert_called() + + @mock.patch('builtins.print') + def test_main_version( + self, + mock_print: mock.Mock) -> None: + with self.assertRaises(SystemExit) as e: + main(['--version']) + mock_print.assert_called_with(__version__) + self.assertEqual(e.exception.code, 0) diff --git a/tests/test_set_open_file_limit.py b/tests/test_set_open_file_limit.py index 90788cce3e..a2be8e6613 100644 --- a/tests/test_set_open_file_limit.py +++ b/tests/test_set_open_file_limit.py @@ -11,7 +11,7 @@ import unittest from unittest import mock -from proxy.main import set_open_file_limit +from proxy.common.flags import Flags if os.name != 'nt': import resource @@ -28,7 +28,7 @@ def test_set_open_file_limit( self, mock_set_rlimit: mock.Mock, mock_get_rlimit: mock.Mock) -> None: - set_open_file_limit(256) + Flags.set_open_file_limit(256) mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) mock_set_rlimit.assert_called_with(resource.RLIMIT_NOFILE, (256, 1024)) @@ -38,7 +38,7 @@ def test_set_open_file_limit_not_called( self, mock_set_rlimit: mock.Mock, mock_get_rlimit: mock.Mock) -> None: - set_open_file_limit(256) + Flags.set_open_file_limit(256) mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) mock_set_rlimit.assert_not_called() @@ -48,6 +48,6 @@ def test_set_open_file_limit_not_called_coz_upper_bound_check( self, mock_set_rlimit: mock.Mock, mock_get_rlimit: mock.Mock) -> None: - set_open_file_limit(1024) + Flags.set_open_file_limit(1024) mock_get_rlimit.assert_called_with(resource.RLIMIT_NOFILE) mock_set_rlimit.assert_not_called() From 439d58fdc26332496a43dcdc439440cfd1cc6293 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 14 Nov 2019 19:00:07 -0800 Subject: [PATCH 036/107] Devtools Protocol (#174) * Refine docs * Decouple relay from dashboard. Will be re-used by devtools protocol plugin. * Just have a single manager for all eventing * Ofcourse managers cant be shared across processes * Remove unused * Add DevtoolsProtocolPlugin * Emit REQUEST_COMPLETE core event * Emit only if --enable-events used * Add event emitter for response cycle * Fill up core events to devtools protocol expectations * Serve static content with Cache-Control header and gzip compression * Add PWA manifest.json and icons from sample PWA apps (replace later) * Catch any exception and be ssl agnostic * Add CSP headers and avoid inline scripts * Re-enable iframe and deobfuscation * Embed plugins within
    block * Make tab switching agnostic of block name * Add support for browser history on tab change * Default hash to #home * Switch to tab if hash is already set * Expand canvas to fill screen even without content * Remove inline css for embedded devtools * Make dashboard backend websocket API pluggable * doc --- README.md | 19 +- benchmark/__init__.py | 11 + dashboard/dashboard.py | 170 +++++----- dashboard/rollup.config.js | 9 +- dashboard/src/core/plugin.ts | 10 +- dashboard/src/core/plugins/home.ts | 17 + dashboard/src/core/plugins/inspect_traffic.ts | 11 +- dashboard/src/core/plugins/settings.ts | 2 +- dashboard/src/core/ws.ts | 13 - dashboard/src/manifest.json | 34 ++ dashboard/src/proxy.css | 34 +- dashboard/src/proxy.html | 37 ++- dashboard/src/proxy.ts | 50 ++- .../static/images/icons/icon-128x128.png | Bin 0 -> 5754 bytes .../static/images/icons/icon-144x144.png | Bin 0 -> 6721 bytes .../static/images/icons/icon-152x152.png | Bin 0 -> 7330 bytes .../static/images/icons/icon-192x192.png | Bin 0 -> 9793 bytes .../static/images/icons/icon-256x256.png | Bin 0 -> 15072 bytes dashboard/static/images/icons/icon-32x32.png | Bin 0 -> 1231 bytes .../static/images/icons/icon-512x512.png | Bin 0 -> 37670 bytes ...0.min.js => js.cookie-3.0.0-beta.0.min.js} | 0 plugin_examples/cache_responses.py | 10 +- proxy/__init__.py | 7 +- proxy/common/flags.py | 22 +- proxy/core/event.py | 67 +++- proxy/core/threadless.py | 53 +-- proxy/http/devtools.py | 314 ------------------ proxy/http/handler.py | 13 +- proxy/http/inspector.py | 223 +++++++++++++ proxy/http/proxy.py | 274 +++++++++------ proxy/http/server.py | 52 +-- .../http/test_http_proxy_tls_interception.py | 8 +- tests/http/test_web_server.py | 8 +- 33 files changed, 810 insertions(+), 658 deletions(-) create mode 100644 dashboard/src/manifest.json create mode 100755 dashboard/static/images/icons/icon-128x128.png create mode 100755 dashboard/static/images/icons/icon-144x144.png create mode 100755 dashboard/static/images/icons/icon-152x152.png create mode 100755 dashboard/static/images/icons/icon-192x192.png create mode 100755 dashboard/static/images/icons/icon-256x256.png create mode 100755 dashboard/static/images/icons/icon-32x32.png create mode 100644 dashboard/static/images/icons/icon-512x512.png rename dashboard/static/{js.cookie-v3.0.0-beta.0.min.js => js.cookie-3.0.0-beta.0.min.js} (100%) delete mode 100644 proxy/http/devtools.py create mode 100644 proxy/http/inspector.py diff --git a/README.md b/README.md index 55399595c0..a944fc02b3 100644 --- a/README.md +++ b/README.md @@ -724,7 +724,7 @@ Embed proxy.py ## Blocking Mode Start `proxy.py` in embedded mode with default configuration -by using the `main` method. Example: +by using `proxy.main` method. Example: ``` import proxy @@ -782,10 +782,10 @@ Note that: 1. `start` is a context manager. It will start `proxy.py` when called and will shut it down once scope ends. -3. Just like `main`, startup flags with `start` method too +3. Just like `main`, startup flags with `start` method can be customized by either passing flags as list of - input arguments `start(['--port', '8899'])` or by using passing - flags as kwargs `start(port=8899)`. + input arguments e.g. `start(['--port', '8899'])` or + by using passing flags as kwargs e.g. `start(port=8899)`. Unit testing with proxy.py ========================== @@ -1131,13 +1131,14 @@ usage: proxy [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] [--client-recvbuf-size CLIENT_RECVBUF_SIZE] [--devtools-ws-path DEVTOOLS_WS_PATH] [--disable-headers DISABLE_HEADERS] [--disable-http-proxy] - [--enable-devtools] [--enable-events] [--enable-static-server] - [--enable-web-server] [--hostname HOSTNAME] [--key-file KEY_FILE] + [--enable-devtools] [--enable-events] + [--enable-static-server] [--enable-web-server] + [--hostname HOSTNAME] [--key-file KEY_FILE] [--log-level LOG_LEVEL] [--log-file LOG_FILE] [--log-format LOG_FORMAT] [--num-workers NUM_WORKERS] [--open-file-limit OPEN_FILE_LIMIT] [--pac-file PAC_FILE] - [--pac-file-url-path PAC_FILE_URL_PATH] [--pid-file PID_FILE] - [--plugins PLUGINS] [--port PORT] + [--pac-file-url-path PAC_FILE_URL_PATH] + [--pid-file PID_FILE] [--plugins PLUGINS] [--port PORT] [--server-recvbuf-size SERVER_RECVBUF_SIZE] [--static-server-dir STATIC_SERVER_DIR] [--threadless] [--timeout TIMEOUT] [--version] @@ -1186,7 +1187,7 @@ optional arguments: --disable-http-proxy Default: False. Whether to disable proxy.HttpProxyPlugin. --enable-devtools Default: False. Enables integration with Chrome - Devtool Frontend. + Devtool Frontend. Also see --devtools-ws-path. --enable-events Default: False. Enables core to dispatch lifecycle events. Plugins can be used to subscribe for core events. diff --git a/benchmark/__init__.py b/benchmark/__init__.py index e69de29bb2..2699e754bc 100644 --- a/benchmark/__init__.py +++ b/benchmark/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index 378c88a73e..21fa5da9d3 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -9,35 +9,99 @@ """ import os import json -import queue import logging -import threading -import multiprocessing -import uuid -from typing import List, Tuple, Optional, Any, Dict +from abc import ABC, abstractmethod +from typing import List, Tuple, Any, Dict +from proxy.common.flags import Flags +from proxy.core.event import EventSubscriber from proxy.http.server import HttpWebServerPlugin, HttpWebServerBasePlugin, httpProtocolTypes from proxy.http.parser import HttpParser from proxy.http.websocket import WebsocketFrame from proxy.http.codes import httpStatusCodes from proxy.common.utils import build_http_response, bytes_ -from proxy.common.types import DictQueueType from proxy.core.connection import TcpClientConnection logger = logging.getLogger(__name__) -class ProxyDashboard(HttpWebServerBasePlugin): +class ProxyDashboardWebsocketPlugin(ABC): + """Abstract class for plugins extending dashboard websocket API.""" + + def __init__( + self, + flags: Flags, + client: TcpClientConnection, + subscriber: EventSubscriber) -> None: + self.flags = flags + self.client = client + self.subscriber = subscriber + + @abstractmethod + def methods(self) -> List[str]: + """Return list of methods that this plugin will handle.""" + pass + + @abstractmethod + def handle_message(self, message: Dict[str, Any]) -> None: + """Handle messages for registered methods.""" + pass + + def reply(self, data: Dict[str, Any]) -> None: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(data)))) + + +class InspectTrafficPlugin(ProxyDashboardWebsocketPlugin): + """Websocket API for inspect_traffic.ts frontend plugin.""" - RELAY_MANAGER: multiprocessing.managers.SyncManager = multiprocessing.Manager() + def methods(self) -> List[str]: + return [ + 'enable_inspection', + 'disable_inspection', + ] + + def handle_message(self, message: Dict[str, Any]) -> None: + if message['method'] == 'enable_inspection': + # inspection can only be enabled if --enable-events is used + if not self.flags.enable_events: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps( + {'id': message['id'], 'response': 'not enabled'}) + ) + ) + ) + else: + self.subscriber.subscribe( + lambda event: ProxyDashboard.callback( + self.client, event)) + self.reply( + {'id': message['id'], 'response': 'inspection_enabled'}) + elif message['method'] == 'disable_inspection': + self.subscriber.unsubscribe() + self.reply({'id': message['id'], + 'response': 'inspection_disabled'}) + else: + raise NotImplementedError() + + +class ProxyDashboard(HttpWebServerBasePlugin): + """Proxy Dashboard.""" def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.inspection_enabled: bool = False - self.relay_thread: Optional[threading.Thread] = None - self.relay_shutdown: Optional[threading.Event] = None - self.relay_channel: Optional[DictQueueType] = None - self.relay_sub_id: Optional[str] = None + self.subscriber = EventSubscriber(self.event_queue) + # Initialize Websocket API plugins + self.plugins: Dict[str, ProxyDashboardWebsocketPlugin] = {} + plugins = [InspectTrafficPlugin] + for plugin in plugins: + p = plugin(self.flags, self.client, self.subscriber) + for method in p.methods(): + self.plugins[method] = p def routes(self) -> List[Tuple[int, bytes]]: return [ @@ -83,40 +147,11 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: logger.info(frame.opcode) return - if message['method'] == 'ping': + method = message['method'] + if method == 'ping': self.reply({'id': message['id'], 'response': 'pong'}) - elif message['method'] == 'enable_inspection': - # inspection can only be enabled if --enable-events is used - if not self.flags.enable_events: - self.client.queue( - WebsocketFrame.text( - bytes_( - json.dumps( - {'id': message['id'], 'response': 'not enabled'}) - ) - ) - ) - else: - self.inspection_enabled = True - - self.relay_shutdown = threading.Event() - self.relay_channel = ProxyDashboard.RELAY_MANAGER.Queue() - self.relay_thread = threading.Thread( - target=self.relay_events, - args=(self.relay_shutdown, self.relay_channel, self.client)) - self.relay_thread.start() - - self.relay_sub_id = uuid.uuid4().hex - self.event_queue.subscribe( - self.relay_sub_id, self.relay_channel) - - self.reply( - {'id': message['id'], 'response': 'inspection_enabled'}) - elif message['method'] == 'disable_inspection': - self.shutdown_relay() - self.inspection_enabled = False - self.reply({'id': message['id'], - 'response': 'inspection_disabled'}) + elif method in self.plugins: + self.plugins[method].handle_message(message) else: logger.info(frame.data) logger.info(frame.opcode) @@ -124,24 +159,7 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: def on_websocket_close(self) -> None: logger.info('app ws closed') - self.shutdown_relay() - - def shutdown_relay(self) -> None: - if not self.inspection_enabled: - return - - assert self.relay_shutdown - assert self.relay_thread - assert self.relay_sub_id - - self.event_queue.unsubscribe(self.relay_sub_id) - self.relay_shutdown.set() - self.relay_thread.join() - - self.relay_thread = None - self.relay_shutdown = None - self.relay_channel = None - self.relay_sub_id = None + # unsubscribe def reply(self, data: Dict[str, Any]) -> None: self.client.queue( @@ -150,21 +168,9 @@ def reply(self, data: Dict[str, Any]) -> None: json.dumps(data)))) @staticmethod - def relay_events( - shutdown: threading.Event, - channel: DictQueueType, - client: TcpClientConnection) -> None: - while not shutdown.is_set(): - try: - ev = channel.get(timeout=1) - ev['push'] = 'inspect_traffic' - client.queue( - WebsocketFrame.text( - bytes_( - json.dumps(ev)))) - except queue.Empty: - pass - except EOFError: - break - except KeyboardInterrupt: - break + def callback(client: TcpClientConnection, event: Dict[str, Any]) -> None: + event['push'] = 'inspect_traffic' + client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(event)))) diff --git a/dashboard/rollup.config.js b/dashboard/rollup.config.js index 45160fe9ef..73f96853e9 100644 --- a/dashboard/rollup.config.js +++ b/dashboard/rollup.config.js @@ -1,6 +1,6 @@ import typescript from 'rollup-plugin-typescript'; import copy from 'rollup-plugin-copy'; -// import obfuscatorPlugin from 'rollup-plugin-javascript-obfuscator'; +import obfuscatorPlugin from 'rollup-plugin-javascript-obfuscator'; export const input = 'src/proxy.ts'; export const output = { @@ -21,6 +21,9 @@ export const plugins = [ }, { src: 'src/proxy.css', dest: '../public/dashboard', + }, { + src: 'src/manifest.json', + dest: '../public/dashboard', }, { src: 'src/core/plugins/inspect_traffic.json', dest: '../public/dashboard/devtools' @@ -32,7 +35,7 @@ export const plugins = [ dest: '../public/dashboard/devtools' }], }), - /* obfuscatorPlugin({ + obfuscatorPlugin({ log: false, sourceMap: true, compact: true, @@ -42,5 +45,5 @@ export const plugins = [ stringArrayThreshold: 1, stringArrayEncoding: 'rc4', identifierNamesGenerator: 'mangled', - }) */ + }) ]; diff --git a/dashboard/src/core/plugin.ts b/dashboard/src/core/plugin.ts index 13a5014f1f..d384cbc6d8 100644 --- a/dashboard/src/core/plugin.ts +++ b/dashboard/src/core/plugin.ts @@ -12,6 +12,7 @@ import { WebsocketApi } from './ws' export interface IDashboardPlugin { name: string title: string + tabId(): string initializeTab(): JQuery initializeHeader(): JQuery initializeBody(): JQuery @@ -30,11 +31,16 @@ export abstract class DashboardPlugin implements IDashboardPlugin { this.websocketApi = websocketApi } + public tabId () : string { + return this.name + '_tab' + } + public makeTab (name: string, icon: string) : JQuery { return $('') .attr({ - href: '#', - plugin_name: this.name + href: '#' + this.name, + plugin_name: this.name, + id: this.tabId() }) .addClass('nav-link') .text(name) diff --git a/dashboard/src/core/plugins/home.ts b/dashboard/src/core/plugins/home.ts index e9b603e548..3b542ced1e 100644 --- a/dashboard/src/core/plugins/home.ts +++ b/dashboard/src/core/plugins/home.ts @@ -21,6 +21,23 @@ export class HomePlugin extends DashboardPlugin { return this.makeHeader(this.title) } + // Show following metrics on home page: + // 0. Uptime + // 1. Total number of requests served counter + // - Separate numbers for proxy and in-built http server + // 2. Number of active requests counter + // - Click to inspect via inspect traffic tab + // - Will be hard here to focus on exact active request within embedded Devtools + // 3. Requests served per second / minute / hours chart + // 4. Active requests per second / minute / hours chart + // 5. List of all proxy.py processes + // - Threads per process + // - RAM / CPU per process over time charts + // 6. Bandwidth served + // - Total incoming bytes + // - Total outgoing bytes + // - Ingress / Egress bytes per sec / min / hour + // 7. Active plugin list public initializeBody (): JQuery { return $('
    ') } diff --git a/dashboard/src/core/plugins/inspect_traffic.ts b/dashboard/src/core/plugins/inspect_traffic.ts index 6f25d9e978..f4a4f0efbf 100644 --- a/dashboard/src/core/plugins/inspect_traffic.ts +++ b/dashboard/src/core/plugins/inspect_traffic.ts @@ -9,8 +9,6 @@ */ import { DashboardPlugin } from '../plugin' -declare const Root: any - export class InspectTrafficPlugin extends DashboardPlugin { public name: string = 'inspect_traffic' public title: string = 'Inspect Traffic' @@ -28,12 +26,15 @@ export class InspectTrafficPlugin extends DashboardPlugin { } public activated (): void { - this.websocketApi.enableInspection(this.handleEvents.bind(this)) + this.websocketApi.sendMessage( + { method: 'enable_inspection' }, + this.handleEvents.bind(this)) this.ensureIFrame() } public deactivated (): void { - this.websocketApi.disableInspection() + this.websocketApi.sendMessage( + { method: 'disable_inspection' }) } public handleEvents (message: Record): void { @@ -57,7 +58,7 @@ export class InspectTrafficPlugin extends DashboardPlugin { private getDevtoolsIFrame (): JQuery { return $('') .attr('id', this.getDevtoolsIFrameID()) - .attr('height', '80%') + .attr('height', '100%') .attr('width', '100%') .attr('padding', '0') .attr('margin', '0') diff --git a/dashboard/src/core/plugins/settings.ts b/dashboard/src/core/plugins/settings.ts index ab88962489..2862933b05 100644 --- a/dashboard/src/core/plugins/settings.ts +++ b/dashboard/src/core/plugins/settings.ts @@ -14,7 +14,7 @@ export class SettingsPlugin extends DashboardPlugin { public title: string = 'Settings' public initializeTab () : JQuery { - return this.makeTab(this.title, 'fa-clog') + return this.makeTab(this.title, 'fa-cog') } public initializeHeader (): JQuery { diff --git a/dashboard/src/core/ws.ts b/dashboard/src/core/ws.ts index 82e67d87c8..804e534644 100644 --- a/dashboard/src/core/ws.ts +++ b/dashboard/src/core/ws.ts @@ -41,19 +41,6 @@ export class WebsocketApi { return date.getTime() } - public enableInspection (eventCallback?: MessageHandler) { - // TODO: Set flag to true only once response has been received from the server - this.inspectionEnabled = true - this.inspectionCallback = eventCallback - this.sendMessage({ method: 'enable_inspection' }) - } - - public disableInspection () { - this.inspectionEnabled = false - this.inspectionCallback = null - this.sendMessage({ method: 'disable_inspection' }) - } - private scheduleServerConnect (after_ms: number = this.scheduleReconnectEveryMs) { this.clearServerConnectTimer() this.serverConnectTimer = window.setTimeout( diff --git a/dashboard/src/manifest.json b/dashboard/src/manifest.json new file mode 100644 index 0000000000..75ed311dca --- /dev/null +++ b/dashboard/src/manifest.json @@ -0,0 +1,34 @@ +{ + "name": "Proxy.py Dashboard", + "short_name": "Proxy.py Dashboard", + "start_url": "/dashboard/", + "display": "standalone", + "background_color": "#3E4EB8", + "theme_color": "#2F3BA2", + "icons": [{ + "src": "/dashboard/images/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, { + "src": "/dashboard/images/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, { + "src": "/dashboard/images/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png" + }, { + "src": "/dashboard/images/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, { + "src": "/dashboard/images/icons/icon-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, { + "src": "/dashboard/images/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/dashboard/src/proxy.css b/dashboard/src/proxy.css index b96ca241b6..dc8d82ceeb 100644 --- a/dashboard/src/proxy.css +++ b/dashboard/src/proxy.css @@ -6,15 +6,29 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. - */ +*/ + +/** Main dashboard CSS **/ + +html, body { + height: 100%; +} + +main { + height: 88%; +} + +section { + position: relative; + height: 100%; +} + #proxyDashboard { background-color: #eeeeee; - height: 100%; } -#proxyDashboard .remove-api-spec { - top: 10px; - right: 15px; +.proxy-dashboard-plugin { + display: none; } .app-header { @@ -25,11 +39,15 @@ .app-body { padding-left: 15px; padding-right: 15px; - padding-bottom: 50px; + padding-bottom: 5px; + height: 92%; } -.proxy-data { - display: none; +/** mock_rest_api.ts plugin CSS **/ + +.remove-api-spec { + top: 6px; + right: 7px; } .api-path-spec .list-group-item { diff --git a/dashboard/src/proxy.html b/dashboard/src/proxy.html index 42fa84a41e..d364bb80a3 100644 --- a/dashboard/src/proxy.html +++ b/dashboard/src/proxy.html @@ -7,14 +7,27 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. --> + - - - - - Proxy.py Dashboard + + + + + + + + + + + + + + + +
    @@ -32,7 +45,9 @@
    -
    +
    +
    +
    @@ -48,16 +63,10 @@
    + - - - - + diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index d24deb7914..c518dca780 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -22,7 +22,6 @@ import { TrafficControlPlugin } from './plugins/traffic_control' export class ProxyDashboard { private static plugins: IPluginConstructor[] = []; private plugins: Map = new Map(); - private readonly websocketApi: WebsocketApi constructor () { @@ -36,9 +35,9 @@ export class ProxyDashboard { .append(p.initializeTab()) ) $('#proxyDashboard').append( - $('
    ') + $('
    ') .attr('id', p.name) - .addClass('proxy-data') + .addClass('proxy-dashboard-plugin') .append( $('
    ') .addClass('app-header') @@ -57,26 +56,52 @@ export class ProxyDashboard { $('#proxyTopNav>ul>li>a').on('click', function () { that.switchTab(this) }) + window.onhashchange = function () { + that.onHashChange() + } + if (window.location.hash === '#' || window.location.hash === '') { + window.location.hash = '#home' + } else { + // This can cause race condition where plugin is activated and + // it tries to use WebsocketAPI before it has successfully connected to the server. + // A solution is to have buffer management within WebsocketAPI or raise an exception + // to try again. + $('#' + this.plugins.get(window.location.hash.substring(1)).tabId()).click() + } } public static addPlugin (Plugin: IPluginConstructor) { ProxyDashboard.plugins.push(Plugin) } - private switchTab (element: HTMLElement) { + private onHashChange () { const activeLi = $('#proxyTopNav>ul>li.active') const activeTabPluginName = activeLi.children('a').attr('plugin_name') - const clickedTabPluginName = $(element).attr('plugin_name') - - activeLi.removeClass('active') - $(element.parentNode).addClass('active') - console.log('Showing plugin content', clickedTabPluginName) + const activeTabHash = activeLi.children('a').attr('href') + if (window.location.hash !== activeTabHash) { + this.navigate(activeTabPluginName, window.location.hash.substring(1)) + } + } + private switchTab (element: HTMLElement) { + const activeTabPluginName = $('#proxyTopNav>ul>li.active').children('a').attr('plugin_name') + const clickedTabPluginName = $(element).attr('plugin_name') if (clickedTabPluginName === activeTabPluginName) { return } - $('#proxyDashboard>div.proxy-data').hide() + this.navigate(activeTabPluginName, clickedTabPluginName) + window.history.pushState(null, null, '/dashboard/#' + clickedTabPluginName) + } + + private navigate (activeTabPluginName: string, clickedTabPluginName: string) { + console.log('Navigating from', activeTabPluginName, 'to', clickedTabPluginName) + if (activeTabPluginName !== undefined) { + $('#' + this.plugins.get(activeTabPluginName).tabId()).parent('li').removeClass('active') + } + $('#' + this.plugins.get(clickedTabPluginName).tabId()).parent('li').addClass('active') + + $('#proxyDashboard>.proxy-dashboard-plugin').hide() $('#' + clickedTabPluginName).show() if (activeTabPluginName !== undefined) { @@ -91,6 +116,7 @@ ProxyDashboard.addPlugin(MockRestApiPlugin) ProxyDashboard.addPlugin(InspectTrafficPlugin) ProxyDashboard.addPlugin(ShortlinkPlugin) ProxyDashboard.addPlugin(TrafficControlPlugin) -ProxyDashboard.addPlugin(SettingsPlugin); +ProxyDashboard.addPlugin(SettingsPlugin) -(window as any).ProxyDashboard = ProxyDashboard +const dashboard = new ProxyDashboard() +console.log(dashboard) diff --git a/dashboard/static/images/icons/icon-128x128.png b/dashboard/static/images/icons/icon-128x128.png new file mode 100755 index 0000000000000000000000000000000000000000..975efa1f031a85136e5965b6f1308b8bc4c32ae9 GIT binary patch literal 5754 zcmZu#X*kpm)cwuR3}c@eyX?;Z<7^v?_Z(Z1T^MoAvdg^LJ#7?Y>*StWEttBUc^Jzi|~} z|EIut&YW(3X@P#N&0WQY8tl5?aMEKj?OnxC-KgK|o;GFM1g_pcQk+*0ygaiOth%%k zEIp&{7O$2RBHmRXGP6QIa`l<{Rg(<(8VK*;Y-{g8w5)X#CRE#hJxx6R?Sgfc|%4m!V>TYj}(wLRH{njU*jj1 zE8+|CzBw~l8ssPMLp-d-DdS}gNS_i4bIbKvg$Ow*z~|>c8^O>2_TT9hea+ddDl=Kv zv-BH#YiwO7kA-!D=N}Zh9B|X+crY^I%*-QI#0*4<>F}SIv{ib$dl?DE)t%0vE*~RP zvvTP+QsTDvzm(1o82lHIrRS(0StLT_ggw}pe&?14{A6?!$<}pUfM(To!m~`BEP6Wq zbc@Z@1s}c%#DH;3jctbT-h^cmh1OCN_RP2V`nPtE`d@wtbGrAI%^Hk)t7;V(fxNFYRvVI&?qh^-8h zh!o`bX~il77kkU_cAA-q=2y>e2K3U(KgeUy{+AOgD8kP!(9VqyFTwVE31 zB?CScA=-H;Ar&yY;0`l*1@^H-OPE57WHH9sS1`V9R@g`5CFj`j)X{jAjeZ%TxDCvs z`@UFu3NU&Nx@`ftvX1EA(*MDw15T?QeMENKDa7wXLC%|y+A@g1ku9D1biDLeRkkyo znu61j?DNO}b)YRN{JEb@yof;{LUN(maR?gs!J|PA+swPKErV(L0lADMmPZzte2j|Q z-l=#lP7~>d^r3RI@h!jRpjjX1<;1HC6l;?t_Nx_JFfS<2Gw2kJsv%GihvEg=74dJ? zw_57vQ{t9K^uC`pbGKWC`KPWs5)1h;Sitq!TmZRhFUbhK{?W{_TVV07@2Sw@D`({g z=x#a*Fed>t|FaUSd3KXS-QCH+aj@jWTB;1SnccT^quEWINnd8kMKGE3 zD12;f?AFP#axxZd$nW?Hd<|s_VLS3465aY%c}pG)&+K^jHxIBJN9(XLi8Snc8L(?! zE*1hxe%I_N9O_%5L_{MmC7)lv8h7b7fiwEeyjI7_I}w!=^_^~F@-ibLFpJK)F%}tLVvxI=tqVqOdxV0;! zx_!+NM?QM{Njp+g6Qh!n(hSQdp1LdhcH$e|WEi+Mj~N+atTL5nOP`b71(dI=y^OrV~M8VD4pA5|^~VlE3S7qN3`Hu*zL7-v8&->3{ zK9L+utH=>M$d2qrZCJp_b)K^61MX(g0i>%07*smRQmh__ioPd?l8=}0NGUn&4yK_W z!Qw{FU@ZE8d6n>pow0iM*y$s8NBYb&36hZG?0V97V(zX0+_WAerpSS^wLc3%rD4`a z$v;jAef+sMHgr|5aK3#+=KIYckHAlTPBM~OvKpN`RNuIoT_WMp3Wbl0>^EXjt}j3q z*ch!lpAP5w$C_9-OtxSFM;^Ak6OOnylK7lH+{`HbfK9X&s~{SYCat9)1xf*###2tY zCxpg}P2k2LAri!nhIl#all_tiFR|fgRu+9;vhLsB{^-~Wr7YquM?k&B7=3@7z(!9h zjGgF6(VP@JnmNRs)_zTFBaD6Wyz$0cRFF+X_o;wrYjgy6ECO#|XTZ3?L6+z~&16zJ zOsfWv#q>bv#M?-u$jZX;@bnMkJIvvgQno<&G7Q15jF3$Cd?pR)e1Y+tJ+pQG)YpPI zRtSw7S#~p?Sf1z-VUK{`pujyhz-;V?YIo#s8)k%Y`$ z^KFHb8ZNKZKsSQNn`jD%dPT`(KqewWRx6)r_Uge4ue}@$aKj{;-%5+(rD% z=aK_!y_5|#O@|6tf-%WN1SNTXf~;ypxX45qsQ$**(*EaQKW2Ej0t$m6wXV+6_c%16Fi039$6r_4oAaTFLjPk z-IWhGcnC^V?p^51!bYyZey!5$WxYF=9bu#Rk;qN!}AB}CaC^?-EdiO;jb}d13eUwoR4+_H`_-Zy$sdr31o|wUUELn z{mRiQE2SNV5_9dRmPlc6mQxwz5k3x1uh5X@Nmj|FZL8?2G)6?08YA;}H6y6U3k2Q-iUmEMrb8`{POT4e z5F+x$1Vy=x3*4PPGOA8O1hvwmw! zLj&4c%t7cg4psSqIMqj|?b478Z-|D2jBzMzx?-&6s7VuAk63D&LvuqM|E?%H2H9?7 z-IrmgN_mRU8qlhO!obqYxl%>M5Td5NucW9hb&K0mY>u}aysvX&gme+(LwI30uNMpz zE$sYe;6A6^>t2nhi9frtTyr2ocwtbz0E}A_) z_6zsqvZWiR(-&o|7f~rsDkC<;lmlkOu@$&{(X08d$wfs)&$O`~dA>n1GLf!aBm&RM zuNzSlALF2EYQm+W5+pZEAj}ecU^TY>U7Pr2bd+o^=8&D0^IhqscQU|F3N=f(;5ziX znuy4aK~P?nuNtFH;s6)5;8VCHf#v)lVPE90f0MYA_xRU#O4qG_%%1Qg6qjWnJB4f{ z!vuWa=^|we_fvYd3C2Q9TBG)qJogQk9k2bwWCe&4eSooC?&u5A&~J>%YKLtcV@@3* zsGvI!dyB149>?QFS%W4YL(QWEvjMuB2;P`QN`BiHoG;A4O%1_s;gp&NbZ#q5nfv0M zi98=79;c*(6wYbkNT~c$?p+*qS(1ZT=lPdlFi<~Oez8Qr;aZB^NbS{P-~cd6X!`?d^D&?agV6W-n(A(mlL~hh%Xb8FOps5Uz511jHSDYr16W9m$Bwwd ziw+(*PSnvHJahOc!=CKA+*f2wZmgFlIK|@+?xh7YqcE&i|K62+;4GCHN{V9y#&wv* zAWH~EaoteGO0R*Di<$%G-^Sa%)L-czc0MZkDnXfM_i}+^ax;5f&0|}og-|$wGP>i} z7ORW-nxgoj)ej}uW_)99EvU2>D1pG##R4i@q8rWnP&jYJ)P=(-29m?at)9L<%lhP4 zEL8`&*ZERlcoPBV$VKY`14l1r%=DSij*zbwUy>$=A3zHB{EoJ4w7tK6oF5t*N{xHf z`qEUcScIr&ec}b3wwM)%ZDd%}Y#VYq+isGbx-!v4xP&?@+|f1G`?TX@c#uIN118U+ z9LVor7Y9u|BBrdvsyxZVXL#hofBdPyHXn#ehPQeCqPc+S zSKj9PXeTft_^*dX=+Yism#Lf_zId{Cl9BP)xAf5Xr;y6p3|*cpSQ@#S!+++NQ|S{)hAs~>#KDLKFYiM%FFJTkSsDMZ^J%|VL7bCc`Y_2shDt*3dkQuN!8Px z?K-(>LPYGGB6rVSFM40?_+f`p*~=xlQikHrdstZ=`6b2Y_Vnfgk0FitWDO{PxGOxv zNc1Cfvxi0M4gM{+bev99)|}%N3?l>w+dIkZ@7Pa`v;aGbj* zo_*W>9 zQ+$Tt*9o}N5O3 zC=41T3}zb3a zLJhkU`!C1UNS8G?Y8`N^Jf47w5@L_X*?f6qoR8xda>&nJ5&TWT*qOd1apVOYH zJ1hBmd`l?oB>RCzq7z3_P5a`N-Ry@ccFzwUwN@@(Aq~)2Huf54hS&rbD9V95R%JZ; z$MO@iT2VsKw>df3pVK5(}<-#rPQBQj;xsQqZoegf??(B-@XwI;=< z58|ZnIRexJ&LD;O!wIdySV@I(xyKD*x3*DG&Dv0Xh6HI3s{j7b-6@T&*FixOv5w;1 zna}paQ`UJ7Aa6SA_fJ1+IJ%yS?l*}}>X^qigf58r$7FXEpm_l|0U;i)_OaC|zw z*wvFkTA@hxQ|KdWLd)*CLAiH0*LCfUo`-j#GZ*PoL*Qd;#13{Tu%ZCB)M02Z)FFx_ z!f|i)-3p2PGP!z8<%Var^+Nu{SeSONw~IN%?I10u87<8i)FB+!NfApqKD%D0)>>+9 zTT}g6u~<_6Ed^+Zo5!`I1-D9&dTM=FD={>Cx($!Hb1Z2+L)d zV%PFr>!rOd?tNF!|{y?uVeBf8*dvu8M8F@j!|^bF@*L(@>C*!i8!eI+DX?4J{%K`Wck+&%Y^ zZs{Cpw+_>zv_D?`ICOpAD7%JBZ^1M}-1QaP-iG5;@)*e6nX|fA=E%lYW|qHRbsW=~ z#w&l5j(c?;?{<&wNB=~YZs-_80WV)j;$)PC6TA1Jegtat|IcRscPs08f;KMFX6Y(X S%Keuq1BSZSu+`eQ$o~P@fSn!y literal 0 HcmV?d00001 diff --git a/dashboard/static/images/icons/icon-144x144.png b/dashboard/static/images/icons/icon-144x144.png new file mode 100755 index 0000000000000000000000000000000000000000..869590ad1796f29c04ac96289da5d1adc649f476 GIT binary patch literal 6721 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 JD%Y?J{~zAwa(4g# literal 0 HcmV?d00001 diff --git a/dashboard/static/images/icons/icon-152x152.png b/dashboard/static/images/icons/icon-152x152.png new file mode 100755 index 0000000000000000000000000000000000000000..12966b5ea1102429d06d3c922ed6cb46044876b3 GIT binary patch literal 7330 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 literal 0 HcmV?d00001 diff --git a/dashboard/static/images/icons/icon-192x192.png b/dashboard/static/images/icons/icon-192x192.png new file mode 100755 index 0000000000000000000000000000000000000000..79f255d419ee6822dd814e519c467c78b874f25e GIT binary patch literal 9793 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 BhZq0= literal 0 HcmV?d00001 diff --git a/dashboard/static/images/icons/icon-256x256.png b/dashboard/static/images/icons/icon-256x256.png new file mode 100755 index 0000000000000000000000000000000000000000..d5af13f39d2cc6b7521fb777adcb01f7a0badfe8 GIT binary patch literal 15072 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
  • U-%P-`qDnJ#O4r-&x+i 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 literal 0 HcmV?d00001 diff --git a/dashboard/static/js.cookie-v3.0.0-beta.0.min.js b/dashboard/static/js.cookie-3.0.0-beta.0.min.js similarity index 100% rename from dashboard/static/js.cookie-v3.0.0-beta.0.min.js rename to dashboard/static/js.cookie-3.0.0-beta.0.min.js diff --git a/plugin_examples/cache_responses.py b/plugin_examples/cache_responses.py index 797a247b31..63e5c61b10 100644 --- a/plugin_examples/cache_responses.py +++ b/plugin_examples/cache_responses.py @@ -12,10 +12,8 @@ import tempfile import time import logging -from typing import Optional, BinaryIO +from typing import Optional, BinaryIO, Any -from proxy.common.flags import Flags -from proxy.core.connection import TcpClientConnection from proxy.http.parser import HttpParser from proxy.http.proxy import HttpProxyBasePlugin from proxy.common.utils import text_ @@ -29,10 +27,8 @@ class CacheResponsesPlugin(HttpProxyBasePlugin): CACHE_DIR = tempfile.gettempdir() def __init__( - self, - config: Flags, - client: TcpClientConnection) -> None: - super().__init__(config, client) + self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) self.cache_file_path: Optional[str] = None self.cache_file: Optional[BinaryIO] = None diff --git a/proxy/__init__.py b/proxy/__init__.py index e496b84569..dcab933901 100755 --- a/proxy/__init__.py +++ b/proxy/__init__.py @@ -12,12 +12,13 @@ from .proxy import TestCase __all__ = [ - # PyPi package entry_point. See + # PyPi package entry_point. See # https://github.com/abhinavsingh/proxy.py#from-command-line-when-installed-using-pip 'entry_point', - # Embed mode. See https://github.com/abhinavsingh/proxy.py#embed-proxypy + # Embed proxy.py. See + # https://github.com/abhinavsingh/proxy.py#embed-proxypy 'main', 'start', - # Unit testing with proxy.py. See + # Unit testing with proxy.py. See # https://github.com/abhinavsingh/proxy.py#unit-testing-with-proxypy 'TestCase' ] diff --git a/proxy/common/flags.py b/proxy/common/flags.py index efd5c58fdd..3a80a0f193 100644 --- a/proxy/common/flags.py +++ b/proxy/common/flags.py @@ -22,7 +22,6 @@ from typing import Optional, Union, Dict, List, TypeVar, Type, cast, Any from .utils import text_, bytes_ -from .types import DictQueueType from .constants import DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_BACKLOG, DEFAULT_BASIC_AUTH from .constants import DEFAULT_TIMEOUT, DEFAULT_DEVTOOLS_WS_PATH, DEFAULT_DISABLE_HTTP_PROXY, DEFAULT_DISABLE_HEADERS from .constants import DEFAULT_ENABLE_STATIC_SERVER, DEFAULT_ENABLE_EVENTS, DEFAULT_ENABLE_DEVTOOLS @@ -72,7 +71,6 @@ def __init__( backlog: int = DEFAULT_BACKLOG, static_server_dir: str = DEFAULT_STATIC_SERVER_DIR, enable_static_server: bool = DEFAULT_ENABLE_STATIC_SERVER, - devtools_event_queue: Optional[DictQueueType] = None, devtools_ws_path: bytes = DEFAULT_DEVTOOLS_WS_PATH, timeout: int = DEFAULT_TIMEOUT, threadless: bool = DEFAULT_THREADLESS, @@ -106,10 +104,7 @@ def __init__( self.enable_static_server: bool = enable_static_server self.static_server_dir: str = static_server_dir - - self.devtools_event_queue: Optional[DictQueueType] = devtools_event_queue self.devtools_ws_path: bytes = devtools_ws_path - self.enable_events: bool = enable_events self.proxy_py_data_dir = os.path.join( @@ -158,20 +153,16 @@ def initialize( Flags.set_open_file_limit(args.open_file_limit) default_plugins = '' - devtools_event_queue: Optional[DictQueueType] = None if args.enable_devtools: - default_plugins += 'proxy.http.devtools.DevtoolsProtocolPlugin,' + default_plugins += 'proxy.http.inspector.DevtoolsProtocolPlugin,' default_plugins += 'proxy.http.server.HttpWebServerPlugin,' if not args.disable_http_proxy: default_plugins += 'proxy.http.proxy.HttpProxyPlugin,' if args.enable_web_server or \ args.pac_file is not None or \ - args.enable_static_server: - if 'proxy.http.server.HttpWebServerPlugin' not in default_plugins: - default_plugins += 'proxy.http.server.HttpWebServerPlugin,' - if args.enable_devtools: - default_plugins += 'proxy.http.devtools.DevtoolsWebsocketPlugin,' - devtools_event_queue = multiprocessing.Manager().Queue() + args.enable_static_server and \ + 'proxy.http.server.HttpWebServerPlugin' not in default_plugins: + default_plugins += 'proxy.http.server.HttpWebServerPlugin,' if args.pac_file is not None: default_plugins += 'proxy.http.server.HttpWebServerPacFilePlugin,' @@ -244,9 +235,6 @@ def initialize( opts.get( 'enable_events', args.enable_events)), - devtools_event_queue=cast( - Optional[DictQueueType], opts.get( - 'devtools_event_queue', devtools_event_queue)), plugins=Flags.load_plugins( bytes_( '%s%s' % @@ -347,7 +335,7 @@ def init_parser() -> argparse.ArgumentParser: '--enable-devtools', action='store_true', default=DEFAULT_ENABLE_DEVTOOLS, - help='Default: False. Enables integration with Chrome Devtool Frontend.' + help='Default: False. Enables integration with Chrome Devtool Frontend. Also see --devtools-ws-path.' ) parser.add_argument( '--enable-events', diff --git a/proxy/core/event.py b/proxy/core/event.py index b841db3e54..855f015986 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -13,8 +13,9 @@ import threading import multiprocessing import logging +import uuid -from typing import Dict, Optional, Any, NamedTuple, List +from typing import Dict, Optional, Any, NamedTuple, List, Callable from ..common.types import DictQueueType @@ -26,16 +27,22 @@ ('UNSUBSCRIBE', int), ('WORK_STARTED', int), ('WORK_FINISHED', int), + ('REQUEST_COMPLETE', int), + ('RESPONSE_HEADERS_COMPLETE', int), + ('RESPONSE_CHUNK_RECEIVED', int), + ('RESPONSE_COMPLETE', int), ]) -eventNames = EventNames(1, 2, 3, 4) +eventNames = EventNames(1, 2, 3, 4, 5, 6, 7, 8) class EventQueue: """Global event queue.""" + MANAGER: multiprocessing.managers.SyncManager = multiprocessing.Manager() + def __init__(self) -> None: super().__init__() - self.queue = multiprocessing.Manager().Queue() + self.queue = EventQueue.MANAGER.Queue() def publish( self, @@ -153,3 +160,57 @@ def run(self) -> None: pass except Exception as e: logger.exception('Event dispatcher exception', exc_info=e) + + +class EventSubscriber: + """Core event subscriber.""" + + MANAGER: multiprocessing.managers.SyncManager = multiprocessing.Manager() + + def __init__(self, event_queue: EventQueue) -> None: + self.event_queue = event_queue + self.relay_thread: Optional[threading.Thread] = None + self.relay_shutdown: Optional[threading.Event] = None + self.relay_channel: Optional[DictQueueType] = None + self.relay_sub_id: Optional[str] = None + + def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: + self.relay_shutdown = threading.Event() + self.relay_channel = EventSubscriber.MANAGER.Queue() + self.relay_thread = threading.Thread( + target=self.relay, + args=(self.relay_shutdown, self.relay_channel, callback)) + self.relay_thread.start() + self.relay_sub_id = uuid.uuid4().hex + self.event_queue.subscribe(self.relay_sub_id, self.relay_channel) + + def unsubscribe(self) -> None: + assert self.relay_thread + assert self.relay_shutdown + assert self.relay_channel + assert self.relay_sub_id + + self.event_queue.unsubscribe(self.relay_sub_id) + self.relay_shutdown.set() + self.relay_thread.join() + + self.relay_thread = None + self.relay_shutdown = None + self.relay_channel = None + self.relay_sub_id = None + + @staticmethod + def relay( + shutdown: threading.Event, + channel: DictQueueType, + callback: Callable[[Dict[str, Any]], None]) -> None: + while not shutdown.is_set(): + try: + ev = channel.get(timeout=1) + callback(ev) + except queue.Empty: + pass + except EOFError: + break + except KeyboardInterrupt: + break diff --git a/proxy/core/threadless.py b/proxy/core/threadless.py index a5cb61dfd7..64ae0a357f 100644 --- a/proxy/core/threadless.py +++ b/proxy/core/threadless.py @@ -14,7 +14,6 @@ import asyncio import selectors import contextlib -import ssl import multiprocessing from multiprocessing import connection from multiprocessing.reduction import recv_handle @@ -34,29 +33,6 @@ class ThreadlessWork(ABC): """Implement ThreadlessWork to hook into the event loop provided by Threadless process.""" - def publish_event( - self, - event_name: int, - event_payload: Dict[str, Any], - publisher_id: Optional[str] = None) -> None: - if not self.flags.enable_events: - return - assert self.event_queue - self.event_queue.publish( - self.uid, - event_name, - event_payload, - publisher_id - ) - - def shutdown(self) -> None: - """Must close any opened resources and call super().shutdown().""" - self.publish_event( - event_name=eventNames.WORK_FINISHED, - event_payload={}, - publisher_id=self.__class__.__name__ - ) - @abstractmethod def __init__( self, @@ -96,6 +72,29 @@ def handle_events( def run(self) -> None: pass + def publish_event( + self, + event_name: int, + event_payload: Dict[str, Any], + publisher_id: Optional[str] = None) -> None: + if not self.flags.enable_events: + return + assert self.event_queue + self.event_queue.publish( + self.uid, + event_name, + event_payload, + publisher_id + ) + + def shutdown(self) -> None: + """Must close any opened resources and call super().shutdown().""" + self.publish_event( + event_name=eventNames.WORK_FINISHED, + event_payload={}, + publisher_id=self.__class__.__name__ + ) + class Threadless(multiprocessing.Process): """Threadless provides an event loop. Use it by implementing Threadless class. @@ -182,8 +181,10 @@ def accept_client(self) -> None: ) try: self.works[fileno].initialize() - except ssl.SSLError as e: - logger.exception('ssl.SSLError', exc_info=e) + except Exception as e: + logger.exception( + 'Exception occurred during initialization', + exc_info=e) self.cleanup(fileno) def cleanup_inactive(self) -> None: diff --git a/proxy/http/devtools.py b/proxy/http/devtools.py deleted file mode 100644 index 8eeb62f32a..0000000000 --- a/proxy/http/devtools.py +++ /dev/null @@ -1,314 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import threading -import queue -import socket -import time -import secrets -import os -import logging -import json -from typing import Optional, Union, List, Tuple, Dict, Any - -from .parser import httpParserStates, httpParserTypes, HttpParser -from .server import HttpWebServerBasePlugin, httpProtocolTypes -from .websocket import WebsocketFrame, websocketOpcodes -from .handler import HttpProtocolHandlerPlugin - -from ..common.constants import COLON, PROXY_PY_START_TIME -from ..common.types import HasFileno, DictQueueType -from ..common.utils import bytes_, text_ - -from ..core.connection import TcpClientConnection - -logger = logging.getLogger(__name__) - - -class DevtoolsWebsocketPlugin(HttpWebServerBasePlugin): - """DevtoolsWebsocketPlugin handles Devtools Frontend websocket requests. - - For every connected Devtools Frontend instance, a dispatcher thread is - started which drains the global Devtools protocol events queue. - - Dispatcher thread is terminated when Devtools Frontend disconnects.""" - - def __init__(self, *args: Any, **kwargs: Any): - super().__init__(*args, **kwargs) - self.event_dispatcher_thread: Optional[threading.Thread] = None - self.event_dispatcher_shutdown: Optional[threading.Event] = None - - def start_dispatcher(self) -> None: - self.event_dispatcher_shutdown = threading.Event() - assert self.flags.devtools_event_queue is not None - self.event_dispatcher_thread = threading.Thread( - target=DevtoolsWebsocketPlugin.event_dispatcher, - args=(self.event_dispatcher_shutdown, - self.flags.devtools_event_queue, - self.client)) - self.event_dispatcher_thread.start() - - def stop_dispatcher(self) -> None: - assert self.event_dispatcher_shutdown is not None - assert self.event_dispatcher_thread is not None - self.event_dispatcher_shutdown.set() - self.event_dispatcher_thread.join() - logger.debug('Event dispatcher shutdown') - - @staticmethod - def event_dispatcher( - shutdown: threading.Event, - devtools_event_queue: DictQueueType, - client: TcpClientConnection) -> None: - while not shutdown.is_set(): - try: - ev = devtools_event_queue.get(timeout=1) - frame = WebsocketFrame() - frame.fin = True - frame.opcode = websocketOpcodes.TEXT_FRAME - frame.data = bytes_(json.dumps(ev)) - logger.debug(ev) - client.queue(frame.build()) - except queue.Empty: - pass - except Exception as e: - logger.exception('Event dispatcher exception', exc_info=e) - break - except KeyboardInterrupt: - break - - def routes(self) -> List[Tuple[int, bytes]]: - return [ - (httpProtocolTypes.WEBSOCKET, self.flags.devtools_ws_path) - ] - - def handle_request(self, request: HttpParser) -> None: - pass - - def on_websocket_open(self) -> None: - self.start_dispatcher() - - def on_websocket_message(self, frame: WebsocketFrame) -> None: - if frame.data: - message = json.loads(frame.data) - self.handle_message(message) - else: - logger.debug('No data found in frame') - - def on_websocket_close(self) -> None: - self.stop_dispatcher() - - def handle_message(self, message: Dict[str, Any]) -> None: - frame = WebsocketFrame() - frame.fin = True - frame.opcode = websocketOpcodes.TEXT_FRAME - - if message['method'] in ( - 'Page.canScreencast', - 'Network.canEmulateNetworkConditions', - 'Emulation.canEmulate' - ): - data = json.dumps({ - 'id': message['id'], - 'result': False - }) - elif message['method'] == 'Page.getResourceTree': - data = json.dumps({ - 'id': message['id'], - 'result': { - 'frameTree': { - 'frame': { - 'id': 1, - 'url': 'http://proxypy', - 'mimeType': 'other', - }, - 'childFrames': [], - 'resources': [] - } - } - }) - elif message['method'] == 'Network.getResponseBody': - logger.debug('received request method Network.getResponseBody') - data = json.dumps({ - 'id': message['id'], - 'result': { - 'body': '', - 'base64Encoded': False, - } - }) - else: - data = json.dumps({ - 'id': message['id'], - 'result': {}, - }) - - frame.data = bytes_(data) - self.client.queue(frame.build()) - - -class DevtoolsProtocolPlugin(HttpProtocolHandlerPlugin): - """ - DevtoolsProtocolPlugin taps into core `HttpProtocolHandler` - events and converts them into Devtools Protocol json messages. - - A DevtoolsProtocolPlugin instance is created per request. - Per request devtool events are queued into a global multiprocessing queue. - """ - - frame_id = secrets.token_hex(8) - loader_id = secrets.token_hex(8) - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.id: str = f'{ os.getpid() }-{ threading.get_ident() }-{ time.time() }' - self.response = HttpParser(httpParserTypes.RESPONSE_PARSER) - - def get_descriptors( - self) -> Tuple[List[socket.socket], List[socket.socket]]: - return [], [] - - def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: - return False - - def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: - return False - - def on_client_data(self, raw: bytes) -> Optional[bytes]: - return raw - - def on_request_complete(self) -> Union[socket.socket, bool]: - if not self.request.has_upstream_server() and \ - self.request.path == self.config.devtools_ws_path: - return False - - # Handle devtool frontend websocket upgrade - if self.config.devtools_event_queue: - self.config.devtools_event_queue.put({ - 'method': 'Network.requestWillBeSent', - 'params': self.request_will_be_sent(), - }) - return False - - def on_response_chunk(self, chunk: bytes) -> bytes: - if not self.request.has_upstream_server() and \ - self.request.path == self.config.devtools_ws_path: - return chunk - - if self.config.devtools_event_queue: - self.response.parse(chunk) - if self.response.state >= httpParserStates.HEADERS_COMPLETE: - self.config.devtools_event_queue.put({ - 'method': 'Network.responseReceived', - 'params': self.response_received(), - }) - if self.response.state >= httpParserStates.RCVING_BODY: - self.config.devtools_event_queue.put({ - 'method': 'Network.dataReceived', - 'params': self.data_received(chunk) - }) - if self.response.state == httpParserStates.COMPLETE: - self.config.devtools_event_queue.put({ - 'method': 'Network.loadingFinished', - 'params': self.loading_finished() - }) - return chunk - - def on_client_connection_close(self) -> None: - pass - - def request_will_be_sent(self) -> Dict[str, Any]: - now = time.time() - return { - 'requestId': self.id, - 'loaderId': self.loader_id, - 'documentURL': 'http://proxy-py', - 'request': { - 'url': text_( - self.request.path - if self.request.has_upstream_server() else - b'http://' + bytes_(str(self.config.hostname)) + - COLON + bytes_(self.config.port) + self.request.path - ), - 'urlFragment': '', - 'method': text_(self.request.method), - 'headers': {text_(v[0]): text_(v[1]) for v in self.request.headers.values()}, - 'initialPriority': 'High', - 'mixedContentType': 'none', - 'postData': None if self.request.method != 'POST' - else text_(self.request.body) - }, - 'timestamp': now - PROXY_PY_START_TIME, - 'wallTime': now, - 'initiator': { - 'type': 'other' - }, - 'type': text_(self.request.header(b'content-type')) - if self.request.has_header(b'content-type') - else 'Other', - 'frameId': self.frame_id, - 'hasUserGesture': False - } - - def response_received(self) -> Dict[str, Any]: - return { - 'requestId': self.id, - 'frameId': self.frame_id, - 'loaderId': self.loader_id, - 'timestamp': time.time(), - 'type': text_(self.response.header(b'content-type')) - if self.response.has_header(b'content-type') - else 'Other', - 'response': { - 'url': '', - 'status': '', - 'statusText': '', - 'headers': '', - 'headersText': '', - 'mimeType': '', - 'connectionReused': True, - 'connectionId': '', - 'encodedDataLength': '', - 'fromDiskCache': False, - 'fromServiceWorker': False, - 'timing': { - 'requestTime': '', - 'proxyStart': -1, - 'proxyEnd': -1, - 'dnsStart': -1, - 'dnsEnd': -1, - 'connectStart': -1, - 'connectEnd': -1, - 'sslStart': -1, - 'sslEnd': -1, - 'workerStart': -1, - 'workerReady': -1, - 'sendStart': 0, - 'sendEnd': 0, - 'receiveHeadersEnd': 0, - }, - 'requestHeaders': '', - 'remoteIPAddress': '', - 'remotePort': '', - } - } - - def data_received(self, chunk: bytes) -> Dict[str, Any]: - return { - 'requestId': self.id, - 'timestamp': time.time(), - 'dataLength': len(chunk), - 'encodedDataLength': len(chunk), - } - - def loading_finished(self) -> Dict[str, Any]: - return { - 'requestId': self.id, - 'timestamp': time.time(), - 'encodedDataLength': self.response.total_size - } diff --git a/proxy/http/handler.py b/proxy/http/handler.py index d14165c5ef..fcc763a3c8 100644 --- a/proxy/http/handler.py +++ b/proxy/http/handler.py @@ -53,11 +53,13 @@ class HttpProtocolHandlerPlugin(ABC): def __init__( self, - config: Flags, + uid: str, + flags: Flags, client: TcpClientConnection, request: HttpParser, event_queue: EventQueue): - self.config: Flags = config + self.uid: str = uid + self.flags: Flags = flags self.client: TcpClientConnection = client self.request: HttpParser = request self.event_queue = event_queue @@ -77,11 +79,11 @@ def get_descriptors( @abstractmethod def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: - pass # pragma: no cover + return False # pragma: no cover @abstractmethod def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: - pass # pragma: no cover + return False # pragma: no cover @abstractmethod def on_client_data(self, raw: bytes) -> Optional[bytes]: @@ -90,7 +92,7 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: @abstractmethod def on_request_complete(self) -> Union[socket.socket, bool]: """Called right after client request parser has reached COMPLETE state.""" - pass # pragma: no cover + return False # pragma: no cover @abstractmethod def on_response_chunk(self, chunk: bytes) -> bytes: @@ -135,6 +137,7 @@ def initialize(self) -> None: if b'HttpProtocolHandlerPlugin' in self.flags.plugins: for klass in self.flags.plugins[b'HttpProtocolHandlerPlugin']: instance = klass( + self.uid, self.flags, self.client, self.request, diff --git a/proxy/http/inspector.py b/proxy/http/inspector.py new file mode 100644 index 0000000000..ca0feedebc --- /dev/null +++ b/proxy/http/inspector.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import json +import logging +import secrets +import time +from typing import List, Tuple, Any, Dict + +from .parser import HttpParser +from .websocket import WebsocketFrame, websocketOpcodes +from .server import HttpWebServerBasePlugin, httpProtocolTypes + +from ..common.constants import PROXY_PY_START_TIME +from ..common.utils import bytes_, text_ +from ..core.connection import TcpClientConnection +from ..core.event import EventSubscriber, eventNames + +logger = logging.getLogger(__name__) + + +class DevtoolsProtocolPlugin(HttpWebServerBasePlugin): + """Speaks DevTools protocol with client over websocket.""" + + DOC_URL = 'http://dashboard.proxy.py' + FRAME_ID = secrets.token_hex(8) + LOADER_ID = secrets.token_hex(8) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.subscriber = EventSubscriber(self.event_queue) + + def routes(self) -> List[Tuple[int, bytes]]: + return [ + (httpProtocolTypes.WEBSOCKET, self.flags.devtools_ws_path) + ] + + def handle_request(self, request: HttpParser) -> None: + raise NotImplementedError('This should have never been called') + + def on_websocket_open(self) -> None: + self.subscriber.subscribe( + lambda event: CoreEventsToDevtoolsProtocol.transformer(self.client, event)) + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + try: + assert frame.data + message = json.loads(frame.data) + except UnicodeDecodeError: + logger.error(frame.data) + logger.info(frame.opcode) + return + self.handle_devtools_message(message) + + def on_websocket_close(self) -> None: + self.subscriber.unsubscribe() + + def handle_devtools_message(self, message: Dict[str, Any]) -> None: + frame = WebsocketFrame() + frame.fin = True + frame.opcode = websocketOpcodes.TEXT_FRAME + + # logger.info(message) + method = message['method'] + if method in ( + 'Page.canScreencast', + 'Network.canEmulateNetworkConditions', + 'Emulation.canEmulate', + ): + data: Dict[str, Any] = { + 'result': False + } + elif method == 'Page.getResourceTree': + data = { + 'result': { + 'frameTree': { + 'frame': { + 'id': 1, + 'url': DevtoolsProtocolPlugin.DOC_URL, + 'mimeType': 'other', + }, + 'childFrames': [], + 'resources': [] + } + } + } + elif method == 'Network.getResponseBody': + connection_id = message['params']['requestId'] + data = { + 'result': { + 'body': text_(CoreEventsToDevtoolsProtocol.RESPONSES[connection_id]), + 'base64Encoded': False, + } + } + else: + logging.warning('Unhandled devtools method %s', method) + data = {} + + data['id'] = message['id'] + frame.data = bytes_(json.dumps(data)) + self.client.queue(frame.build()) + + +class CoreEventsToDevtoolsProtocol: + + RESPONSES: Dict[str, bytes] = {} + + @staticmethod + def transformer(client: TcpClientConnection, + event: Dict[str, Any]) -> None: + event_name = event['event_name'] + if event_name == eventNames.REQUEST_COMPLETE: + data = CoreEventsToDevtoolsProtocol.request_complete(event) + elif event_name == eventNames.RESPONSE_HEADERS_COMPLETE: + data = CoreEventsToDevtoolsProtocol.response_headers_complete( + event) + elif event_name == eventNames.RESPONSE_CHUNK_RECEIVED: + data = CoreEventsToDevtoolsProtocol.response_chunk_received(event) + elif event_name == eventNames.RESPONSE_COMPLETE: + data = CoreEventsToDevtoolsProtocol.response_complete(event) + else: + # drop core events unrelated to Devtools + return + client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(data)))) + + @staticmethod + def request_complete(event: Dict[str, Any]) -> Dict[str, Any]: + now = time.time() + return { + 'requestId': event['request_id'], + 'frameId': DevtoolsProtocolPlugin.FRAME_ID, + 'loaderId': DevtoolsProtocolPlugin.LOADER_ID, + 'documentURL': DevtoolsProtocolPlugin.DOC_URL, + 'timestamp': now - PROXY_PY_START_TIME, + 'wallTime': now, + 'hasUserGesture': False, + 'type': event['event_payload']['headers']['content-type'] + if event['event_payload']['headers'].has_header('content-type') + else 'Other', + 'request': { + 'url': event['event_payload']['url'], + 'method': event['event_payload']['method'], + 'headers': event['event_payload']['headers'], + 'postData': event['event_payload']['body'], + 'initialPriority': 'High', + 'urlFragment': '', + 'mixedContentType': 'none', + }, + 'initiator': { + 'type': 'other' + }, + } + + @staticmethod + def response_headers_complete(event: Dict[str, Any]) -> Dict[str, Any]: + return { + 'requestId': event['request_id'], + 'frameId': DevtoolsProtocolPlugin.FRAME_ID, + 'loaderId': DevtoolsProtocolPlugin.LOADER_ID, + 'timestamp': time.time(), + 'type': event['event_payload']['headers']['content-type'] + if event['event_payload']['headers'].has_header('content-type') + else 'Other', + 'response': { + 'url': '', + 'status': '', + 'statusText': '', + 'headers': '', + 'headersText': '', + 'mimeType': '', + 'connectionReused': True, + 'connectionId': '', + 'encodedDataLength': '', + 'fromDiskCache': False, + 'fromServiceWorker': False, + 'timing': { + 'requestTime': '', + 'proxyStart': -1, + 'proxyEnd': -1, + 'dnsStart': -1, + 'dnsEnd': -1, + 'connectStart': -1, + 'connectEnd': -1, + 'sslStart': -1, + 'sslEnd': -1, + 'workerStart': -1, + 'workerReady': -1, + 'sendStart': 0, + 'sendEnd': 0, + 'receiveHeadersEnd': 0, + }, + 'requestHeaders': '', + 'remoteIPAddress': '', + 'remotePort': '', + } + } + + @staticmethod + def response_chunk_received(event: Dict[str, Any]) -> Dict[str, Any]: + return { + 'requestId': event['request_id'], + 'timestamp': time.time(), + 'dataLength': event['event_payload']['chunk_size'], + 'encodedDataLength': event['event_payload']['encoded_chunk_size'], + } + + @staticmethod + def response_complete(event: Dict[str, Any]) -> Dict[str, Any]: + return { + 'requestId': event['request_id'], + 'timestamp': time.time(), + 'encodedDataLength': event['event_payload']['encoded_response_size'], + 'shouldReportCorbBlocking': False, + } diff --git a/proxy/http/proxy.py b/proxy/http/proxy.py index 8df0505913..378a720eaf 100644 --- a/proxy/http/proxy.py +++ b/proxy/http/proxy.py @@ -18,6 +18,7 @@ from abc import ABC, abstractmethod from typing import Optional, List, Union, Dict, cast, Any, Tuple +from proxy.core.event import eventNames, EventQueue from .handler import HttpProtocolHandlerPlugin from .exception import HttpProtocolException, ProxyConnectionFailed, ProxyAuthenticationFailed from .codes import httpStatusCodes @@ -41,10 +42,14 @@ class HttpProxyBasePlugin(ABC): def __init__( self, - config: Flags, - client: TcpClientConnection): - self.config = config # pragma: no cover - self.client = client # pragma: no cover + uid: str, + flags: Flags, + client: TcpClientConnection, + event_queue: EventQueue): + self.uid = uid # pragma: no cover + self.flags = flags # pragma: no cover + self.client = client # pragma: no cover + self.event_queue = event_queue # pragma: no cover def name(self) -> str: """A unique name for your plugin. @@ -118,9 +123,13 @@ def __init__( self.pipeline_response: Optional[HttpParser] = None self.plugins: Dict[str, HttpProxyBasePlugin] = {} - if b'HttpProxyBasePlugin' in self.config.plugins: - for klass in self.config.plugins[b'HttpProxyBasePlugin']: - instance = klass(self.config, self.client) + if b'HttpProxyBasePlugin' in self.flags.plugins: + for klass in self.flags.plugins[b'HttpProxyBasePlugin']: + instance = klass( + self.uid, + self.flags, + self.client, + self.event_queue) self.plugins[instance.name()] = instance def get_descriptors( @@ -159,7 +168,7 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: ) and self.server and not self.server.closed and self.server.connection in r: logger.debug('Server is ready for reads, reading...') try: - raw = self.server.recv(self.config.server_recvbuf_size) + raw = self.server.recv(self.flags.server_recvbuf_size) except ssl.SSLWantReadError: # Try again later # logger.warning('SSLWantReadError encountered while reading from server, will retry ...') return False @@ -188,46 +197,16 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: # # or self.config.tls_interception_enabled(): if self.response.state == httpParserStates.COMPLETE: - if self.pipeline_response is None: - self.pipeline_response = HttpParser( - httpParserTypes.RESPONSE_PARSER) - self.pipeline_response.parse(raw) - if self.pipeline_response.state == httpParserStates.COMPLETE: - self.pipeline_response = None + self.handle_pipeline_response(raw) else: self.response.parse(raw) + self.emit_response_events() else: self.response.total_size += len(raw) # queue raw data for client self.client.queue(raw) return False - def access_log(self) -> None: - server_host, server_port = self.server.addr if self.server else ( - None, None) - connection_time_ms = (time.time() - self.start_time) * 1000 - if self.request.method == b'CONNECT': - logger.info( - '%s:%s - %s %s:%s - %s bytes - %.2f ms' % - (self.client.addr[0], - self.client.addr[1], - text_(self.request.method), - text_(server_host), - text_(server_port), - self.response.total_size, - connection_time_ms)) - elif self.request.method: - logger.info( - '%s:%s - %s %s:%s%s - %s %s - %s bytes - %.2f ms' % - (self.client.addr[0], self.client.addr[1], - text_(self.request.method), - text_(server_host), server_port, - text_(self.request.path), - text_(self.response.code), - text_(self.response.reason), - self.response.total_size, - connection_time_ms)) - def on_client_connection_close(self) -> None: if not self.request.has_upstream_server(): return @@ -277,7 +256,7 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: if self.server and not self.server.closed: if self.request.state == httpParserStates.COMPLETE and ( self.request.method != httpMethods.CONNECT or - self.config.tls_interception_enabled()): + self.flags.tls_interception_enabled()): if self.pipeline_request is None: self.pipeline_request = HttpParser( httpParserTypes.REQUEST_PARSER) @@ -298,72 +277,12 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: else: return raw - @staticmethod - def generated_cert_file_path(ca_cert_dir: str, host: str) -> str: - return os.path.join(ca_cert_dir, '%s.pem' % host) - - def generate_upstream_certificate( - self, _certificate: Optional[Dict[str, Any]]) -> str: - if not (self.config.ca_cert_dir and self.config.ca_signing_key_file and - self.config.ca_cert_file and self.config.ca_key_file): - raise HttpProtocolException( - f'For certificate generation all the following flags are mandatory: ' - f'--ca-cert-file:{ self.config.ca_cert_file }, ' - f'--ca-key-file:{ self.config.ca_key_file }, ' - f'--ca-signing-key-file:{ self.config.ca_signing_key_file }') - cert_file_path = HttpProxyPlugin.generated_cert_file_path( - self.config.ca_cert_dir, text_(self.request.host)) - with self.lock: - if not os.path.isfile(cert_file_path): - logger.debug('Generating certificates %s', cert_file_path) - # TODO: Parse subject from certificate - # Currently we only set CN= field for generated certificates. - gen_cert = subprocess.Popen( - ['openssl', 'req', '-new', '-key', self.config.ca_signing_key_file, '-subj', - f'/C=/ST=/L=/O=/OU=/CN={ text_(self.request.host) }'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - sign_cert = subprocess.Popen( - ['openssl', 'x509', '-req', '-days', '365', '-CA', self.config.ca_cert_file, '-CAkey', - self.config.ca_key_file, '-set_serial', str(int(time.time())), '-out', cert_file_path], - stdin=gen_cert.stdout, - stderr=subprocess.PIPE) - # TODO: Ensure sign_cert success. - sign_cert.communicate(timeout=10) - return cert_file_path - - def wrap_server(self) -> None: - assert self.server is not None - assert isinstance(self.server.connection, socket.socket) - ctx = ssl.create_default_context( - ssl.Purpose.SERVER_AUTH) - ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 - self.server.connection.setblocking(True) - self.server._conn = ctx.wrap_socket( - self.server.connection, - server_hostname=text_(self.request.host)) - self.server.connection.setblocking(False) - - def wrap_client(self) -> None: - assert self.server is not None - assert isinstance(self.server.connection, ssl.SSLSocket) - generated_cert = self.generate_upstream_certificate( - cast(Dict[str, Any], self.server.connection.getpeercert())) - self.client.connection.setblocking(True) - self.client.flush() - self.client._conn = ssl.wrap_socket( - self.client.connection, - server_side=True, - keyfile=self.config.ca_signing_key_file, - certfile=generated_cert) - self.client.connection.setblocking(False) - logger.debug( - 'TLS interception using %s', generated_cert) - def on_request_complete(self) -> Union[socket.socket, bool]: if not self.request.has_upstream_server(): return False + self.emit_request_complete() + self.authenticate() # Note: can raise HttpRequestRejected exception @@ -391,7 +310,7 @@ def on_request_complete(self) -> Union[socket.socket, bool]: self.client.queue( HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) # If interception is enabled - if self.config.tls_interception_enabled(): + if self.flags.tls_interception_enabled(): # Perform SSL/TLS handshake with upstream self.wrap_server() # Generate certificate and perform handshake with client @@ -428,13 +347,109 @@ def on_request_complete(self) -> Union[socket.socket, bool]: # Disable args.disable_headers before dispatching to upstream self.server.queue( self.request.build( - disable_headers=self.config.disable_headers)) + disable_headers=self.flags.disable_headers)) return False + def handle_pipeline_response(self, raw: bytes) -> None: + if self.pipeline_response is None: + self.pipeline_response = HttpParser( + httpParserTypes.RESPONSE_PARSER) + self.pipeline_response.parse(raw) + if self.pipeline_response.state == httpParserStates.COMPLETE: + self.pipeline_response = None + + def access_log(self) -> None: + server_host, server_port = self.server.addr if self.server else ( + None, None) + connection_time_ms = (time.time() - self.start_time) * 1000 + if self.request.method == b'CONNECT': + logger.info( + '%s:%s - %s %s:%s - %s bytes - %.2f ms' % + (self.client.addr[0], + self.client.addr[1], + text_(self.request.method), + text_(server_host), + text_(server_port), + self.response.total_size, + connection_time_ms)) + elif self.request.method: + logger.info( + '%s:%s - %s %s:%s%s - %s %s - %s bytes - %.2f ms' % + (self.client.addr[0], self.client.addr[1], + text_(self.request.method), + text_(server_host), server_port, + text_(self.request.path), + text_(self.response.code), + text_(self.response.reason), + self.response.total_size, + connection_time_ms)) + + @staticmethod + def generated_cert_file_path(ca_cert_dir: str, host: str) -> str: + return os.path.join(ca_cert_dir, '%s.pem' % host) + + def generate_upstream_certificate( + self, _certificate: Optional[Dict[str, Any]]) -> str: + if not (self.flags.ca_cert_dir and self.flags.ca_signing_key_file and + self.flags.ca_cert_file and self.flags.ca_key_file): + raise HttpProtocolException( + f'For certificate generation all the following flags are mandatory: ' + f'--ca-cert-file:{ self.flags.ca_cert_file }, ' + f'--ca-key-file:{ self.flags.ca_key_file }, ' + f'--ca-signing-key-file:{ self.flags.ca_signing_key_file }') + cert_file_path = HttpProxyPlugin.generated_cert_file_path( + self.flags.ca_cert_dir, text_(self.request.host)) + with self.lock: + if not os.path.isfile(cert_file_path): + logger.debug('Generating certificates %s', cert_file_path) + # TODO: Parse subject from certificate + # Currently we only set CN= field for generated certificates. + gen_cert = subprocess.Popen( + ['openssl', 'req', '-new', '-key', self.flags.ca_signing_key_file, '-subj', + f'/C=/ST=/L=/O=/OU=/CN={ text_(self.request.host) }'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + sign_cert = subprocess.Popen( + ['openssl', 'x509', '-req', '-days', '365', '-CA', self.flags.ca_cert_file, '-CAkey', + self.flags.ca_key_file, '-set_serial', str(int(time.time())), '-out', cert_file_path], + stdin=gen_cert.stdout, + stderr=subprocess.PIPE) + # TODO: Ensure sign_cert success. + sign_cert.communicate(timeout=10) + return cert_file_path + + def wrap_server(self) -> None: + assert self.server is not None + assert isinstance(self.server.connection, socket.socket) + ctx = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH) + ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + self.server.connection.setblocking(True) + self.server._conn = ctx.wrap_socket( + self.server.connection, + server_hostname=text_(self.request.host)) + self.server.connection.setblocking(False) + + def wrap_client(self) -> None: + assert self.server is not None + assert isinstance(self.server.connection, ssl.SSLSocket) + generated_cert = self.generate_upstream_certificate( + cast(Dict[str, Any], self.server.connection.getpeercert())) + self.client.connection.setblocking(True) + self.client.flush() + self.client._conn = ssl.wrap_socket( + self.client.connection, + server_side=True, + keyfile=self.flags.ca_signing_key_file, + certfile=generated_cert) + self.client.connection.setblocking(False) + logger.debug( + 'TLS interception using %s', generated_cert) + def authenticate(self) -> None: - if self.config.auth_code: + if self.flags.auth_code: if b'proxy-authorization' not in self.request.headers or \ - self.request.headers[b'proxy-authorization'][1] != self.config.auth_code: + self.request.headers[b'proxy-authorization'][1] != self.flags.auth_code: raise ProxyAuthenticationFailed() def connect_upstream(self) -> None: @@ -456,3 +471,48 @@ def connect_upstream(self) -> None: else: logger.exception('Both host and port must exist') raise HttpProtocolException() + + def emit_request_complete(self) -> None: + if not self.flags.enable_events: + return + + assert self.request.path + assert self.request.port + self.event_queue.publish( + request_id=self.uid, + event_name=eventNames.REQUEST_COMPLETE, + event_payload={ + 'url': text_(self.request.path) + if self.request.method == httpMethods.CONNECT + else 'http://%s:%d%s' % (text_(self.request.host), self.request.port, text_(self.request.path)), + 'method': text_(self.request.method), + 'headers': {text_(k): text_(v[1]) for k, v in self.request.headers.items()}, + 'body': text_(self.request.body) + if self.request.method == httpMethods.POST + else None + }, + publisher_id=self.__class__.__name__ + ) + + def emit_response_events(self) -> None: + if not self.flags.enable_events: + return + + if self.response.state == httpParserStates.COMPLETE: + self.emit_response_complete() + elif self.response.state == httpParserStates.RCVING_BODY: + self.emit_response_chunk_received() + elif self.response.state == httpParserStates.HEADERS_COMPLETE: + self.emit_response_headers_complete() + + def emit_response_headers_complete(self) -> None: + if not self.flags.enable_events: + return + + def emit_response_chunk_received(self) -> None: + if not self.flags.enable_events: + return + + def emit_response_complete(self) -> None: + if not self.flags.enable_events: + return diff --git a/proxy/http/server.py b/proxy/http/server.py index 9fe37d6bc6..7b4478bf21 100644 --- a/proxy/http/server.py +++ b/proxy/http/server.py @@ -7,6 +7,7 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import gzip import time import logging import os @@ -44,9 +45,11 @@ class HttpWebServerBasePlugin(ABC): def __init__( self, + uid: str, flags: Flags, client: TcpClientConnection, event_queue: EventQueue): + self.uid = uid self.flags = flags self.client = client self.event_queue = event_queue @@ -84,19 +87,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.pac_file_response: Optional[bytes] = None self.cache_pac_file_response() - def cache_pac_file_response(self) -> None: - if self.flags.pac_file: - try: - with open(self.flags.pac_file, 'rb') as f: - content = f.read() - except IOError: - content = bytes_(self.flags.pac_file) - self.pac_file_response = build_http_response( - 200, reason=b'OK', headers={ - b'Content-Type': b'application/x-ns-proxy-autoconfig', - }, body=content - ) - def routes(self) -> List[Tuple[int, bytes]]: if self.flags.pac_file_url_path: return [ @@ -118,6 +108,20 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: def on_websocket_close(self) -> None: pass # pragma: no cover + def cache_pac_file_response(self) -> None: + if self.flags.pac_file: + try: + with open(self.flags.pac_file, 'rb') as f: + content = f.read() + except IOError: + content = bytes_(self.flags.pac_file) + self.pac_file_response = build_http_response( + 200, reason=b'OK', headers={ + b'Content-Type': b'application/x-ns-proxy-autoconfig', + b'Content-Encoding': b'gzip', + }, body=gzip.compress(content) + ) + class HttpWebServerPlugin(HttpProtocolHandlerPlugin): """HttpProtocolHandler plugin which handles incoming requests to local web server.""" @@ -150,9 +154,13 @@ def __init__( } self.route: Optional[HttpWebServerBasePlugin] = None - if b'HttpWebServerBasePlugin' in self.config.plugins: - for klass in self.config.plugins[b'HttpWebServerBasePlugin']: - instance = klass(self.config, self.client, self.event_queue) + if b'HttpWebServerBasePlugin' in self.flags.plugins: + for klass in self.flags.plugins[b'HttpWebServerBasePlugin']: + instance = klass( + self.uid, + self.flags, + self.client, + self.event_queue) for (protocol, path) in instance.routes(): self.routes[protocol][path] = instance @@ -168,9 +176,11 @@ def read_and_build_static_file_response(path: str) -> bytes: reason=b'OK', headers={ b'Content-Type': bytes_(content_type), + b'Cache-Control': b'max-age=86400', + b'Content-Encoding': b'gzip', b'Connection': b'close', }, - body=content) + body=gzip.compress(content)) def serve_file_or_404(self, path: str) -> bool: """Read and serves a file from disk. @@ -221,7 +231,7 @@ def on_request_complete(self) -> Union[socket.socket, bool]: # Routing for Http(s) requests protocol = httpProtocolTypes.HTTPS \ - if self.config.encryption_enabled() else \ + if self.flags.encryption_enabled() else \ httpProtocolTypes.HTTP for r in self.routes[protocol]: if r == self.request.path: @@ -230,11 +240,11 @@ def on_request_complete(self) -> Union[socket.socket, bool]: return False # No-route found, try static serving if enabled - if self.config.enable_static_server: + if self.flags.enable_static_server: path = text_(self.request.path).split('?')[0] - if os.path.isfile(self.config.static_server_dir + path): + if os.path.isfile(self.flags.static_server_dir + path): return self.serve_file_or_404( - self.config.static_server_dir + path) + self.flags.static_server_dir + path) # Catch all unhandled web server requests, return 404 self.client.queue(self.DEFAULT_404_RESPONSE) diff --git a/tests/http/test_http_proxy_tls_interception.py b/tests/http/test_http_proxy_tls_interception.py index 42575b0c5b..228b2ea082 100644 --- a/tests/http/test_http_proxy_tls_interception.py +++ b/tests/http/test_http_proxy_tls_interception.py @@ -81,12 +81,12 @@ def mock_connection() -> Any: self.protocol_handler.initialize() self.plugin.assert_called() - self.assertEqual(self.plugin.call_args[0][0], self.flags) - self.assertEqual(self.plugin.call_args[0][1].connection, self._conn) + self.assertEqual(self.plugin.call_args[0][1], self.flags) + self.assertEqual(self.plugin.call_args[0][2].connection, self._conn) self.proxy_plugin.assert_called() - self.assertEqual(self.proxy_plugin.call_args[0][0], self.flags) + self.assertEqual(self.proxy_plugin.call_args[0][1], self.flags) self.assertEqual( - self.proxy_plugin.call_args[0][1].connection, + self.proxy_plugin.call_args[0][2].connection, self._conn) connect_request = build_http_request( diff --git a/tests/http/test_web_server.py b/tests/http/test_web_server.py index b0352d6180..ac7a0e47b4 100644 --- a/tests/http/test_web_server.py +++ b/tests/http/test_web_server.py @@ -7,6 +7,7 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import gzip import os import tempfile import unittest @@ -156,13 +157,16 @@ def test_static_web_server_serves( self.assertEqual(mock_selector.return_value.select.call_count, 2) self.assertEqual(self._conn.send.call_count, 1) + encoded_html_file_content = gzip.compress(html_file_content) self.assertEqual(self._conn.send.call_args[0][0], build_http_response( 200, reason=b'OK', headers={ b'Content-Type': b'text/html', + b'Cache-Control': b'max-age=86400', + b'Content-Encoding': b'gzip', b'Connection': b'close', - b'Content-Length': bytes_(len(html_file_content)), + b'Content-Length': bytes_(len(encoded_html_file_content)), }, - body=html_file_content + body=encoded_html_file_content )) @mock.patch('selectors.DefaultSelector') From 148c2604728620ce7a80d75273a90c97faf3d86a Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 15 Nov 2019 13:29:48 -0800 Subject: [PATCH 037/107] Move dashboard backend within proxy module, now ships via same pip package (#177) * Allow resources to load from http and ws when running w/o https * Move dashboard backend (dashboard.py) within proxy module. Now shipped with pip install proxy.py * Update ref to dashboard backend in github workflows * Add git-pre-commit hook file. Enable it by symlinking as .git/hooks/pre-commit * Also enable static server for dashboard serving --- .github/workflows/test-docker.yml | 4 +- .github/workflows/test-library.yml | 4 +- Makefile | 20 +--- dashboard/dashboard.py | 176 ----------------------------- dashboard/rollup.config.js | 2 +- dashboard/setup.py | 87 -------------- dashboard/src/proxy.html | 2 +- git-pre-commit | 3 + proxy/common/constants.py | 5 +- proxy/common/flags.py | 44 ++++++-- proxy/core/acceptor.py | 1 + proxy/dashboard/__init__.py | 9 ++ proxy/dashboard/dashboard.py | 100 ++++++++++++++++ proxy/dashboard/inspect_traffic.py | 56 +++++++++ proxy/dashboard/plugin.py | 47 ++++++++ requirements-testing.txt | 2 +- 16 files changed, 263 insertions(+), 299 deletions(-) delete mode 100644 dashboard/dashboard.py delete mode 100644 dashboard/setup.py create mode 100755 git-pre-commit create mode 100644 proxy/dashboard/__init__.py create mode 100644 proxy/dashboard/dashboard.py create mode 100644 proxy/dashboard/inspect_traffic.py create mode 100644 proxy/dashboard/plugin.py diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index b50056e8b4..24e8403aee 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -4,11 +4,11 @@ on: [push] jobs: build: - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.os }}-latest name: Python ${{ matrix.python }} on ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu] python: [3.7] max-parallel: 1 fail-fast: false diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 24c575a1ce..f81f718f7b 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -25,8 +25,8 @@ jobs: pip install -r requirements-testing.txt - name: Quality Check run: | - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ setup.py - name: Run Tests run: pytest --cov=proxy tests/ - name: Upload coverage to Codecov diff --git a/Makefile b/Makefile index 17968ed1e4..9bf47dcd27 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,7 @@ CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem .PHONY: all https-certificates ca-certificates autopep8 devtools .PHONY: lib-clean lib-test lib-package lib-release-test lib-release lib-coverage lib-lint lib-profile .PHONY: container container-run container-release -.PHONY: dashboard dashboard-clean dashboard-package -.PHONY: plugin-package-clean plugin-package +.PHONY: dashboard dashboard-clean all: lib-clean lib-test @@ -30,7 +29,6 @@ autopep8: autopep8 --recursive --in-place --aggressive tests/*.py autopep8 --recursive --in-place --aggressive plugin_examples/*.py autopep8 --recursive --in-place --aggressive benchmark/*.py - autopep8 --recursive --in-place --aggressive dashboard/*.py autopep8 --recursive --in-place --aggressive setup.py https-certificates: @@ -61,8 +59,8 @@ lib-clean: rm -rf .hypothesis lib-lint: - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ dashboard/dashboard.py setup.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ setup.py lib-test: lib-lint python -m unittest discover @@ -89,18 +87,6 @@ dashboard: dashboard-clean: if [[ -d public/dashboard ]]; then rm -rf public/dashboard; fi -dashboard-package-clean: - pushd dashboard && rm -rf build && rm -rf dist && popd - -dashboard-package: dashboard-package-clean - pushd dashboard && npm test && PYTHONPATH=.. python setup.py sdist bdist_wheel && popd - -plugin-package-clean: - pushd plugin_examples && rm -rf build && rm -rf dist && popd - -plugin-package: plugin-package-clean - pushd plugin_examples && PYTHONPATH=.. python setup.py sdist bdist_wheel && popd - container: docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py deleted file mode 100644 index 21fa5da9d3..0000000000 --- a/dashboard/dashboard.py +++ /dev/null @@ -1,176 +0,0 @@ -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import os -import json -import logging -from abc import ABC, abstractmethod -from typing import List, Tuple, Any, Dict - -from proxy.common.flags import Flags -from proxy.core.event import EventSubscriber -from proxy.http.server import HttpWebServerPlugin, HttpWebServerBasePlugin, httpProtocolTypes -from proxy.http.parser import HttpParser -from proxy.http.websocket import WebsocketFrame -from proxy.http.codes import httpStatusCodes -from proxy.common.utils import build_http_response, bytes_ -from proxy.core.connection import TcpClientConnection - -logger = logging.getLogger(__name__) - - -class ProxyDashboardWebsocketPlugin(ABC): - """Abstract class for plugins extending dashboard websocket API.""" - - def __init__( - self, - flags: Flags, - client: TcpClientConnection, - subscriber: EventSubscriber) -> None: - self.flags = flags - self.client = client - self.subscriber = subscriber - - @abstractmethod - def methods(self) -> List[str]: - """Return list of methods that this plugin will handle.""" - pass - - @abstractmethod - def handle_message(self, message: Dict[str, Any]) -> None: - """Handle messages for registered methods.""" - pass - - def reply(self, data: Dict[str, Any]) -> None: - self.client.queue( - WebsocketFrame.text( - bytes_( - json.dumps(data)))) - - -class InspectTrafficPlugin(ProxyDashboardWebsocketPlugin): - """Websocket API for inspect_traffic.ts frontend plugin.""" - - def methods(self) -> List[str]: - return [ - 'enable_inspection', - 'disable_inspection', - ] - - def handle_message(self, message: Dict[str, Any]) -> None: - if message['method'] == 'enable_inspection': - # inspection can only be enabled if --enable-events is used - if not self.flags.enable_events: - self.client.queue( - WebsocketFrame.text( - bytes_( - json.dumps( - {'id': message['id'], 'response': 'not enabled'}) - ) - ) - ) - else: - self.subscriber.subscribe( - lambda event: ProxyDashboard.callback( - self.client, event)) - self.reply( - {'id': message['id'], 'response': 'inspection_enabled'}) - elif message['method'] == 'disable_inspection': - self.subscriber.unsubscribe() - self.reply({'id': message['id'], - 'response': 'inspection_disabled'}) - else: - raise NotImplementedError() - - -class ProxyDashboard(HttpWebServerBasePlugin): - """Proxy Dashboard.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.subscriber = EventSubscriber(self.event_queue) - # Initialize Websocket API plugins - self.plugins: Dict[str, ProxyDashboardWebsocketPlugin] = {} - plugins = [InspectTrafficPlugin] - for plugin in plugins: - p = plugin(self.flags, self.client, self.subscriber) - for method in p.methods(): - self.plugins[method] = p - - def routes(self) -> List[Tuple[int, bytes]]: - return [ - # Redirects to /dashboard/ - (httpProtocolTypes.HTTP, b'/dashboard'), - # Redirects to /dashboard/ - (httpProtocolTypes.HTTPS, b'/dashboard'), - # Redirects to /dashboard/ - (httpProtocolTypes.HTTP, b'/dashboard/proxy.html'), - # Redirects to /dashboard/ - (httpProtocolTypes.HTTPS, b'/dashboard/proxy.html'), - (httpProtocolTypes.HTTP, b'/dashboard/'), - (httpProtocolTypes.HTTPS, b'/dashboard/'), - (httpProtocolTypes.WEBSOCKET, b'/dashboard'), - ] - - def handle_request(self, request: HttpParser) -> None: - if request.path == b'/dashboard/': - self.client.queue( - HttpWebServerPlugin.read_and_build_static_file_response( - os.path.join(self.flags.static_server_dir, 'dashboard', 'proxy.html'))) - elif request.path in ( - b'/dashboard', - b'/dashboard/proxy.html'): - self.client.queue(build_http_response( - httpStatusCodes.PERMANENT_REDIRECT, reason=b'Permanent Redirect', - headers={ - b'Location': b'/dashboard/', - b'Content-Length': b'0', - b'Connection': b'close', - } - )) - - def on_websocket_open(self) -> None: - logger.info('app ws opened') - - def on_websocket_message(self, frame: WebsocketFrame) -> None: - try: - assert frame.data - message = json.loads(frame.data) - except UnicodeDecodeError: - logger.error(frame.data) - logger.info(frame.opcode) - return - - method = message['method'] - if method == 'ping': - self.reply({'id': message['id'], 'response': 'pong'}) - elif method in self.plugins: - self.plugins[method].handle_message(message) - else: - logger.info(frame.data) - logger.info(frame.opcode) - self.reply({'id': message['id'], 'response': 'not_implemented'}) - - def on_websocket_close(self) -> None: - logger.info('app ws closed') - # unsubscribe - - def reply(self, data: Dict[str, Any]) -> None: - self.client.queue( - WebsocketFrame.text( - bytes_( - json.dumps(data)))) - - @staticmethod - def callback(client: TcpClientConnection, event: Dict[str, Any]) -> None: - event['push'] = 'inspect_traffic' - client.queue( - WebsocketFrame.text( - bytes_( - json.dumps(event)))) diff --git a/dashboard/rollup.config.js b/dashboard/rollup.config.js index 73f96853e9..48c328b404 100644 --- a/dashboard/rollup.config.js +++ b/dashboard/rollup.config.js @@ -6,7 +6,7 @@ export const input = 'src/proxy.ts'; export const output = { file: '../public/dashboard/proxy.js', format: 'umd', - name: 'projectbundle', + name: 'proxy', sourcemap: true }; export const plugins = [ diff --git a/dashboard/setup.py b/dashboard/setup.py deleted file mode 100644 index f88e0f612b..0000000000 --- a/dashboard/setup.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -from setuptools import setup, find_packages - -VERSION = (0, 1, 0) -__version__ = '.'.join(map(str, VERSION[0:3])) -__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' -__author__ = 'Abhinav Singh' -__author_email__ = 'mailsforabhinav@gmail.com' -__homepage__ = 'https://github.com/abhinavsingh/proxy.py' -__download_url__ = '%s/archive/master.zip' % __homepage__ -__license__ = 'BSD' - -setup( - name='proxy.py-dashboard', - version=__version__, - author=__author__, - author_email=__author_email__, - url=__homepage__, - description=__description__, - long_description=open('README.md').read().strip(), - long_description_content_type='text/markdown', - download_url=__download_url__, - license=__license__, - packages=find_packages(), - install_requires=['proxy.py'], - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Environment :: No Input/Output (Daemon)', - 'Environment :: Web Environment', - 'Environment :: MacOS X', - 'Environment :: Plugins', - 'Environment :: Win32 (MS Windows)', - 'Framework :: Robot Framework', - 'Framework :: Robot Framework :: Library', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Operating System :: MacOS', - 'Operating System :: MacOS :: MacOS 9', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Operating System :: Unix', - 'Operating System :: Microsoft', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows 10', - 'Operating System :: Android', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: Implementation', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Internet', - 'Topic :: Internet :: Proxy Servers', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Browsers', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', - 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Topic :: Software Development :: Debuggers', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: System :: Monitoring', - 'Topic :: System :: Networking', - 'Topic :: System :: Networking :: Firewalls', - 'Topic :: System :: Networking :: Monitoring', - 'Topic :: Utilities', - 'Typing :: Typed', - ], -) diff --git a/dashboard/src/proxy.html b/dashboard/src/proxy.html index d364bb80a3..96155338cc 100644 --- a/dashboard/src/proxy.html +++ b/dashboard/src/proxy.html @@ -14,7 +14,7 @@ + content="default-src http: https: ws: wss: data:; object-src 'none'; script-src 'self'; style-src 'self'"> diff --git a/git-pre-commit b/git-pre-commit new file mode 100755 index 0000000000..2aad486415 --- /dev/null +++ b/git-pre-commit @@ -0,0 +1,3 @@ +#!/bin/bash + +make diff --git a/proxy/common/constants.py b/proxy/common/constants.py index 468b11945d..95a1728acb 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -15,9 +15,11 @@ from .version import __version__ -PROXY_PY_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) PROXY_PY_START_TIME = time.time() +# /path/to/proxy.py/proxy folder +PROXY_PY_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + CRLF = b'\r\n' COLON = b':' WHITESPACE = b' ' @@ -45,6 +47,7 @@ DEFAULT_DEVTOOLS_WS_PATH = b'/devtools' DEFAULT_DISABLE_HEADERS: List[bytes] = [] DEFAULT_DISABLE_HTTP_PROXY = False +DEFAULT_ENABLE_DASHBOARD = False DEFAULT_ENABLE_DEVTOOLS = False DEFAULT_ENABLE_EVENTS = False DEFAULT_EVENTS_QUEUE = None diff --git a/proxy/common/flags.py b/proxy/common/flags.py index 3a80a0f193..7b12411de0 100644 --- a/proxy/common/flags.py +++ b/proxy/common/flags.py @@ -9,6 +9,7 @@ """ import logging import importlib +import collections import argparse import base64 import ipaddress @@ -19,7 +20,7 @@ import inspect import pathlib -from typing import Optional, Union, Dict, List, TypeVar, Type, cast, Any +from typing import Optional, Union, Dict, List, TypeVar, Type, cast, Any, Tuple from .utils import text_, bytes_ from .constants import DEFAULT_LOG_LEVEL, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_BACKLOG, DEFAULT_BASIC_AUTH @@ -30,7 +31,7 @@ from .constants import DEFAULT_PAC_FILE_URL_PATH, DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT from .constants import DEFAULT_NUM_WORKERS, DEFAULT_VERSION, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME from .constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE, DEFAULT_STATIC_SERVER_DIR -from .constants import COMMA, DOT +from .constants import DEFAULT_ENABLE_DASHBOARD, COMMA, DOT from .version import __version__ __homepage__ = 'https://github.com/abhinavsingh/proxy.py' @@ -152,19 +153,32 @@ def initialize( Flags.setup_logger(args.log_file, args.log_level, args.log_format) Flags.set_open_file_limit(args.open_file_limit) - default_plugins = '' + http_proxy_plugin = 'proxy.http.proxy.HttpProxyPlugin' + web_server_plugin = 'proxy.http.server.HttpWebServerPlugin' + pac_file_plugin = 'proxy.http.server.HttpWebServerPacFilePlugin' + devtools_protocol_plugin = 'proxy.http.inspector.DevtoolsProtocolPlugin' + dashboard_plugin = 'proxy.dashboard.dashboard.ProxyDashboard' + inspect_traffic_plugin = 'proxy.dashboard.inspect_traffic.InspectTrafficPlugin' + + default_plugins: List[Tuple[str, bool]] = [] + if args.enable_dashboard: + default_plugins.append((web_server_plugin, True)) + args.enable_static_server = True + default_plugins.append((dashboard_plugin, True)) + default_plugins.append((inspect_traffic_plugin, True)) + args.enable_events = True + args.enable_devtools = True if args.enable_devtools: - default_plugins += 'proxy.http.inspector.DevtoolsProtocolPlugin,' - default_plugins += 'proxy.http.server.HttpWebServerPlugin,' + default_plugins.append((devtools_protocol_plugin, True)) + default_plugins.append((web_server_plugin, True)) if not args.disable_http_proxy: - default_plugins += 'proxy.http.proxy.HttpProxyPlugin,' + default_plugins.append((http_proxy_plugin, True)) if args.enable_web_server or \ args.pac_file is not None or \ - args.enable_static_server and \ - 'proxy.http.server.HttpWebServerPlugin' not in default_plugins: - default_plugins += 'proxy.http.server.HttpWebServerPlugin,' + args.enable_static_server: + default_plugins.append((web_server_plugin, True)) if args.pac_file is not None: - default_plugins += 'proxy.http.server.HttpWebServerPacFilePlugin,' + default_plugins.append((pac_file_plugin, True)) return cls( auth_code=cast(Optional[bytes], opts.get('auth_code', auth_code)), @@ -238,7 +252,8 @@ def initialize( plugins=Flags.load_plugins( bytes_( '%s%s' % - (default_plugins, opts.get('plugins', args.plugins)))), + (text_(COMMA).join(collections.OrderedDict(default_plugins).keys()), + opts.get('plugins', args.plugins)))), pid_file=cast(Optional[str], opts.get('pid_file', args.pid_file))) def tls_interception_enabled(self) -> bool: @@ -331,6 +346,12 @@ def init_parser() -> argparse.ArgumentParser: action='store_true', default=DEFAULT_DISABLE_HTTP_PROXY, help='Default: False. Whether to disable proxy.HttpProxyPlugin.') + parser.add_argument( + '--enable-dashboard', + action='store_true', + default=DEFAULT_ENABLE_DASHBOARD, + help='Default: False. Enables proxy.py dashboard.' + ) parser.add_argument( '--enable-devtools', action='store_true', @@ -474,6 +495,7 @@ def load_plugins(plugins: bytes) -> Dict[bytes, List[type]]: b'HttpProtocolHandlerPlugin': [], b'HttpProxyBasePlugin': [], b'HttpWebServerBasePlugin': [], + b'ProxyDashboardWebsocketPlugin': [] } for plugin_ in plugins.split(COMMA): plugin = text_(plugin_.strip()) diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor.py index 44eed41bd3..0bb1a6aa2e 100644 --- a/proxy/core/acceptor.py +++ b/proxy/core/acceptor.py @@ -110,6 +110,7 @@ def setup(self) -> None: """Listen on port, setup workers and pass server socket to workers.""" self.listen() if self.flags.enable_events: + logger.info('Core Event enabled') self.start_event_dispatcher() self.start_workers() diff --git a/proxy/dashboard/__init__.py b/proxy/dashboard/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/proxy/dashboard/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/dashboard/dashboard.py b/proxy/dashboard/dashboard.py new file mode 100644 index 0000000000..5b4e38d5c8 --- /dev/null +++ b/proxy/dashboard/dashboard.py @@ -0,0 +1,100 @@ +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import json +import logging +from typing import List, Tuple, Any, Dict + +from .plugin import ProxyDashboardWebsocketPlugin + +from ..common.utils import build_http_response, bytes_ +from ..http.server import HttpWebServerPlugin, HttpWebServerBasePlugin, httpProtocolTypes +from ..http.parser import HttpParser +from ..http.websocket import WebsocketFrame +from ..http.codes import httpStatusCodes + +logger = logging.getLogger(__name__) + + +class ProxyDashboard(HttpWebServerBasePlugin): + """Proxy Dashboard.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.plugins: Dict[str, ProxyDashboardWebsocketPlugin] = {} + if b'ProxyDashboardWebsocketPlugin' in self.flags.plugins: + for klass in self.flags.plugins[b'ProxyDashboardWebsocketPlugin']: + p = klass(self.flags, self.client, self.event_queue) + for method in p.methods(): + self.plugins[method] = p + + def routes(self) -> List[Tuple[int, bytes]]: + return [ + # Redirects to /dashboard/ + (httpProtocolTypes.HTTP, b'/dashboard'), + # Redirects to /dashboard/ + (httpProtocolTypes.HTTPS, b'/dashboard'), + # Redirects to /dashboard/ + (httpProtocolTypes.HTTP, b'/dashboard/proxy.html'), + # Redirects to /dashboard/ + (httpProtocolTypes.HTTPS, b'/dashboard/proxy.html'), + (httpProtocolTypes.HTTP, b'/dashboard/'), + (httpProtocolTypes.HTTPS, b'/dashboard/'), + (httpProtocolTypes.WEBSOCKET, b'/dashboard'), + ] + + def handle_request(self, request: HttpParser) -> None: + if request.path == b'/dashboard/': + self.client.queue( + HttpWebServerPlugin.read_and_build_static_file_response( + os.path.join(self.flags.static_server_dir, 'dashboard', 'proxy.html'))) + elif request.path in ( + b'/dashboard', + b'/dashboard/proxy.html'): + self.client.queue(build_http_response( + httpStatusCodes.PERMANENT_REDIRECT, reason=b'Permanent Redirect', + headers={ + b'Location': b'/dashboard/', + b'Content-Length': b'0', + b'Connection': b'close', + } + )) + + def on_websocket_open(self) -> None: + logger.info('app ws opened') + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + try: + assert frame.data + message = json.loads(frame.data) + except UnicodeDecodeError: + logger.error(frame.data) + logger.info(frame.opcode) + return + + method = message['method'] + if method == 'ping': + self.reply({'id': message['id'], 'response': 'pong'}) + elif method in self.plugins: + self.plugins[method].handle_message(message) + else: + logger.info(frame.data) + logger.info(frame.opcode) + self.reply({'id': message['id'], 'response': 'not_implemented'}) + + def on_websocket_close(self) -> None: + logger.info('app ws closed') + # unsubscribe + + def reply(self, data: Dict[str, Any]) -> None: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(data)))) diff --git a/proxy/dashboard/inspect_traffic.py b/proxy/dashboard/inspect_traffic.py new file mode 100644 index 0000000000..5cd9eca18f --- /dev/null +++ b/proxy/dashboard/inspect_traffic.py @@ -0,0 +1,56 @@ +import json +from typing import List, Dict, Any + +from .plugin import ProxyDashboardWebsocketPlugin + +from ..common.utils import bytes_ +from ..core.event import EventSubscriber +from ..core.connection import TcpClientConnection +from ..http.websocket import WebsocketFrame + + +class InspectTrafficPlugin(ProxyDashboardWebsocketPlugin): + """Websocket API for inspect_traffic.ts frontend plugin.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.subscriber = EventSubscriber(self.event_queue) + + def methods(self) -> List[str]: + return [ + 'enable_inspection', + 'disable_inspection', + ] + + def handle_message(self, message: Dict[str, Any]) -> None: + if message['method'] == 'enable_inspection': + # inspection can only be enabled if --enable-events is used + if not self.flags.enable_events: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps( + {'id': message['id'], 'response': 'not enabled'}) + ) + ) + ) + else: + self.subscriber.subscribe( + lambda event: InspectTrafficPlugin.callback( + self.client, event)) + self.reply( + {'id': message['id'], 'response': 'inspection_enabled'}) + elif message['method'] == 'disable_inspection': + self.subscriber.unsubscribe() + self.reply({'id': message['id'], + 'response': 'inspection_disabled'}) + else: + raise NotImplementedError() + + @staticmethod + def callback(client: TcpClientConnection, event: Dict[str, Any]) -> None: + event['push'] = 'inspect_traffic' + client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(event)))) diff --git a/proxy/dashboard/plugin.py b/proxy/dashboard/plugin.py new file mode 100644 index 0000000000..bab9250f5f --- /dev/null +++ b/proxy/dashboard/plugin.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import json +from abc import ABC, abstractmethod +from typing import List, Dict, Any + +from ..common.utils import bytes_ +from ..common.flags import Flags +from ..http.websocket import WebsocketFrame +from ..core.connection import TcpClientConnection +from ..core.event import EventQueue + + +class ProxyDashboardWebsocketPlugin(ABC): + """Abstract class for plugins extending dashboard websocket API.""" + + def __init__( + self, + flags: Flags, + client: TcpClientConnection, + event_queue: EventQueue) -> None: + self.flags = flags + self.client = client + self.event_queue = event_queue + + @abstractmethod + def methods(self) -> List[str]: + """Return list of methods that this plugin will handle.""" + pass + + @abstractmethod + def handle_message(self, message: Dict[str, Any]) -> None: + """Handle messages for registered methods.""" + pass + + def reply(self, data: Dict[str, Any]) -> None: + self.client.queue( + WebsocketFrame.text( + bytes_( + json.dumps(data)))) diff --git a/requirements-testing.txt b/requirements-testing.txt index a9c8c76242..16cff962bc 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,7 +1,7 @@ python-coveralls==2.9.3 coverage==4.5.4 flake8==3.7.9 -pytest==5.2.2 +pytest==5.2.3 pytest-cov==2.8.1 autopep8==1.4.4 mypy==0.740 From 131e9366ac7890373c194718b4687e67ff61cfe5 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 15 Nov 2019 14:47:50 -0800 Subject: [PATCH 038/107] Move plugin_examples/ as proxy.plugin and update readme (#179) * Update dev guide * Move plugin_examples/ as proxy.plugin * Update proxy.plugin ref path in readme * Remove unnecessary port flag * Remove plugin_examples from github workflows * dashboard folder is a npm package not python package anymore * Plugins can now be tried using Docker image --- .github/workflows/test-library.yml | 4 +- Dockerfile | 3 +- Makefile | 5 +- README.md | 73 ++++++++-------- dashboard/__init__.py | 10 --- plugin_examples/README.md | 1 - plugin_examples/__init__.py | 10 --- plugin_examples/setup.py | 87 ------------------- proxy/common/flags.py | 2 +- proxy/plugin/__init__.py | 29 +++++++ .../plugin}/cache_responses.py | 6 +- .../plugin}/filter_by_upstream.py | 8 +- .../plugin}/man_in_the_middle.py | 8 +- .../plugin}/mock_rest_api.py | 8 +- .../plugin}/modify_post_data.py | 8 +- .../plugin}/redirect_to_custom_server.py | 6 +- .../plugin}/shortlink.py | 10 +-- .../plugin}/web_server_route.py | 10 +-- setup.py | 9 +- tests/http/test_http_proxy_examples.py | 11 ++- tests/http/utils.py | 22 ++--- 21 files changed, 125 insertions(+), 205 deletions(-) delete mode 100644 dashboard/__init__.py delete mode 100644 plugin_examples/README.md delete mode 100644 plugin_examples/__init__.py delete mode 100644 plugin_examples/setup.py create mode 100644 proxy/plugin/__init__.py rename {plugin_examples => proxy/plugin}/cache_responses.py (92%) rename {plugin_examples => proxy/plugin}/filter_by_upstream.py (86%) rename {plugin_examples => proxy/plugin}/man_in_the_middle.py (83%) rename {plugin_examples => proxy/plugin}/mock_rest_api.py (92%) rename {plugin_examples => proxy/plugin}/modify_post_data.py (89%) rename {plugin_examples => proxy/plugin}/redirect_to_custom_server.py (91%) rename {plugin_examples => proxy/plugin}/shortlink.py (91%) rename {plugin_examples => proxy/plugin}/web_server_route.py (84%) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index f81f718f7b..6b957fc242 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -25,8 +25,8 @@ jobs: pip install -r requirements-testing.txt - name: Quality Check run: | - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ setup.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ setup.py - name: Run Tests run: pytest --cov=proxy tests/ - name: Upload coverage to Codecov diff --git a/Dockerfile b/Dockerfile index 3b94c1de30..520ba988d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,5 +21,4 @@ COPY --from=builder /deps /usr/local EXPOSE 8899/tcp ENTRYPOINT [ "proxy" ] -CMD [ "--hostname=0.0.0.0", \ - "--port=8899" ] +CMD [ "--hostname=0.0.0.0" ] diff --git a/Makefile b/Makefile index 9bf47dcd27..5f0ac0415a 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,6 @@ autopep8: autopep8 --recursive --in-place --aggressive proxy/*.py autopep8 --recursive --in-place --aggressive proxy/*/*.py autopep8 --recursive --in-place --aggressive tests/*.py - autopep8 --recursive --in-place --aggressive plugin_examples/*.py autopep8 --recursive --in-place --aggressive benchmark/*.py autopep8 --recursive --in-place --aggressive setup.py @@ -59,8 +58,8 @@ lib-clean: rm -rf .hypothesis lib-lint: - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ plugin_examples/ setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ plugin_examples/ setup.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ setup.py lib-test: lib-lint python -m unittest discover diff --git a/README.md b/README.md index a944fc02b3..b96c71364f 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,10 @@ Table of Contents * [Everything is a plugin](#everything-is-a-plugin) * [Internal Architecture](#internal-architecture) * [Internal Documentation](#internal-documentation) - * [Sending a Pull Request](#sending-a-pull-request) + * [Development Guide](#development-guide) + * [Setup Local Environment](#setup-local-environment) + * [Setup pre-commit hook](#setup-pre-commit-hook) + * [Sending a Pull Request](#sending-a-pull-request) * [Utilities](#utilities) * [TCP](#tcp-sockets) * [new_socket_connection](#new_socket_connection) @@ -120,8 +123,8 @@ Features - No external dependency other than standard Python library - Programmable - Optionally enable builtin Web Server - - Customize proxy and http routing via [plugins](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples) - - Enable plugin using command line option e.g. `--plugins plugin_examples/cache_responses.CacheResponsesPlugin` + - Customize proxy and http routing via [plugins](https://github.com/abhinavsingh/proxy.py/tree/develop/proxy/plugin) + - Enable plugin using command line option e.g. `--plugins proxy.plugin.CacheResponsesPlugin` - Plugin API is currently in development phase, expect breaking changes. - Realtime Dashboard - Optionally enable bundled dashboard. @@ -300,10 +303,12 @@ For example, to check `proxy.py` version within Docker image: Plugin Examples =============== -See [plugin_examples](https://github.com/abhinavsingh/proxy.py/tree/develop/plugin_examples) for full code. - -All the examples below also works with `https` traffic but require additional flags and certificate generation. -See [TLS Interception](#tls-interception). +- See [plugin](https://github.com/abhinavsingh/proxy.py/tree/develop/proxy/plugin) module for full code. +- All the bundled plugin examples also works with `https` traffic + - Require additional flags and certificate generation + - See [TLS Interception](#tls-interception). +- Plugin examples are also bundled with Docker image. + - See [Customize startup flags](#customize-startup-flags) to try plugins with Docker image. ## ShortLinkPlugin @@ -313,7 +318,7 @@ Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/shortlink.ShortLinkPlugin + --plugins proxy.plugin.ShortLinkPlugin ``` Now you can speed up your daily browsing experience by visiting your @@ -342,7 +347,7 @@ Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/modify_post_data.ModifyPostDataPlugin + --plugins proxy.plugin.ModifyPostDataPlugin ``` By default plugin replaces POST body content with hardcoded `b'{"key": "modified"}'` @@ -396,7 +401,7 @@ Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/mock_rest_api.ProposedRestApiPlugin + --plugins proxy.plugin.ProposedRestApiPlugin ``` Verify mock API response using `curl -x localhost:8899 http://api.example.com/v1/users/` @@ -428,7 +433,7 @@ Start `proxy.py` and enable inbuilt web server: ``` $ proxy \ --enable-web-server \ - --plugins plugin_examples/redirect_to_custom_server.RedirectToCustomServerPlugin + --plugins proxy.plugin.RedirectToCustomServerPlugin ``` Verify using `curl -v -x localhost:8899 http://google.com` @@ -461,7 +466,7 @@ Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/filter_by_upstream.FilterByUpstreamHostPlugin + --plugins proxy.plugin.FilterByUpstreamHostPlugin ``` Verify using `curl -v -x localhost:8899 http://google.com`: @@ -494,7 +499,7 @@ Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/cache_responses.CacheResponsesPlugin + --plugins proxy.plugin.CacheResponsesPlugin ``` Verify using `curl -v -x localhost:8899 http://httpbin.org/get`: @@ -570,7 +575,7 @@ Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/man_in_the_middle.ManInTheMiddlePlugin + --plugins proxy.plugin.ManInTheMiddlePlugin ``` Verify using `curl -v -x localhost:8899 http://google.com`: @@ -652,7 +657,7 @@ response from the server. Start `proxy.py` as: ``` $ proxy \ - --plugins plugin_examples/cache_responses.CacheResponsesPlugin \ + --plugins proxy.plugin.CacheResponsesPlugin \ --ca-key-file ca-key.pem \ --ca-cert-file ca-cert.pem \ --ca-signing-key-file ca-signing-key.pem @@ -861,10 +866,6 @@ class TestProxyPyEmbedded(unittest.TestCase): Plugin Developer and Contributor Guide ====================================== -Contributors must start `proxy.py` from source to verify and develop new features / fixes. - -See [Run proxy.py from command line using repo source](#from-command-line-using-repo-source) for details. - ## Everything is a plugin As you might have guessed by now, in `proxy.py` everything is a plugin. @@ -912,25 +913,25 @@ and invoke `HttpProxyBasePlugin` lifecycle hooks. Workers are responsible for accepting new client connections and starting `HttpProtocolHandler` thread. -## Sending a Pull Request +## Development Guide -Install dependencies for local development testing: +#### Setup Local Environment -`$ pip install -r requirements-testing.txt` +Contributors must start `proxy.py` from source to verify and develop new features / fixes. +See [Run proxy.py from command line using repo source](#from-command-line-using-repo-source) for details. -Every pull request goes through set of tests which must pass: +#### Setup pre-commit hook -- `mypy`: Run `make lint` locally for compliance check. - Fix all warnings and errors before sending out a PR. +1. `cd /path/to/proxy.py` +2. `ln -s $(PWD)/git-pre-commit .git/hooks/pre-commit` -- `coverage`: Run `make coverage` locally for coverage report. - Its ideal to add tests for any critical change. Depending upon - the change, it's ok if test coverage falls by `<0.5%`. +Pre-commit hook ensures lint checking and library tests passes. -- `formatting`: Run `make autopep8` locally to format the code in-place. - `autopep8` is run with `--aggresive` flag. Sometimes it _may_ result in - weird formatting. But let's stick to one consistent formatting tool. - I am open to flag changes for `autopep8`. +#### Sending a Pull Request + +Every pull request is tested using GitHub actions. +See [GitHub workflow](https://github.com/abhinavsingh/proxy.py/tree/develop/.github/workflows) +for list of tests. ## Utilities @@ -1051,12 +1052,10 @@ Make sure plugin modules are discoverable by adding them to `PYTHONPATH`. Examp ...[redacted]... - Loaded plugin my_app.proxyPlugin ``` -or, make sure to pass fully-qualified path as parameter, e.g. +OR, simply pass fully-qualified path as parameter, e.g. `proxy --plugins /path/to/my/app/my_app.proxyPlugin` -Note that `pip install proxy.py` don't ship [plugin_examples](https://github.com/abhinavsingh/proxy.py/blob/develop/plugin_examples). - ## Unable to connect with proxy.py from remote host Make sure `proxy.py` is listening on correct network interface. @@ -1108,7 +1107,6 @@ Now `proxy.py` logs can be browsed using without any socket leaks. 1. Make use of `--open-file-limit` flag to customize `ulimit -n`. - - To set a value upper than the hard limit, run as root. 2. Make sure to adjust `--backlog` flag for higher concurrency. If nothing helps, [open an issue](https://github.com/abhinavsingh/proxy.py/issues/new) @@ -1131,7 +1129,7 @@ usage: proxy [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] [--client-recvbuf-size CLIENT_RECVBUF_SIZE] [--devtools-ws-path DEVTOOLS_WS_PATH] [--disable-headers DISABLE_HEADERS] [--disable-http-proxy] - [--enable-devtools] [--enable-events] + [--enable-dashboard] [--enable-devtools] [--enable-events] [--enable-static-server] [--enable-web-server] [--hostname HOSTNAME] [--key-file KEY_FILE] [--log-level LOG_LEVEL] [--log-file LOG_FILE] @@ -1186,6 +1184,7 @@ optional arguments: server. --disable-http-proxy Default: False. Whether to disable proxy.HttpProxyPlugin. + --enable-dashboard Default: False. Enables proxy.py dashboard. --enable-devtools Default: False. Enables integration with Chrome Devtool Frontend. Also see --devtools-ws-path. --enable-events Default: False. Enables core to dispatch lifecycle diff --git a/dashboard/__init__.py b/dashboard/__init__.py deleted file mode 100644 index 87af91833d..0000000000 --- a/dashboard/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" diff --git a/plugin_examples/README.md b/plugin_examples/README.md deleted file mode 100644 index 34f725ca17..0000000000 --- a/plugin_examples/README.md +++ /dev/null @@ -1 +0,0 @@ -# Proxy.py Plugins diff --git a/plugin_examples/__init__.py b/plugin_examples/__init__.py deleted file mode 100644 index 87af91833d..0000000000 --- a/plugin_examples/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" diff --git a/plugin_examples/setup.py b/plugin_examples/setup.py deleted file mode 100644 index 5e23a56e20..0000000000 --- a/plugin_examples/setup.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -from setuptools import setup, find_packages - -VERSION = (0, 1, 0) -__version__ = '.'.join(map(str, VERSION[0:3])) -__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' -__author__ = 'Abhinav Singh' -__author_email__ = 'mailsforabhinav@gmail.com' -__homepage__ = 'https://github.com/abhinavsingh/proxy.py' -__download_url__ = '%s/archive/master.zip' % __homepage__ -__license__ = 'BSD' - -setup( - name='proxy.py-plugins', - version=__version__, - author=__author__, - author_email=__author_email__, - url=__homepage__, - description=__description__, - long_description=open('README.md').read().strip(), - long_description_content_type='text/markdown', - download_url=__download_url__, - license=__license__, - packages=find_packages(), - install_requires=['proxy.py'], - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Environment :: No Input/Output (Daemon)', - 'Environment :: Web Environment', - 'Environment :: MacOS X', - 'Environment :: Plugins', - 'Environment :: Win32 (MS Windows)', - 'Framework :: Robot Framework', - 'Framework :: Robot Framework :: Library', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Operating System :: MacOS', - 'Operating System :: MacOS :: MacOS 9', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Operating System :: Unix', - 'Operating System :: Microsoft', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows 10', - 'Operating System :: Android', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: Implementation', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Internet', - 'Topic :: Internet :: Proxy Servers', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Browsers', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', - 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Topic :: Software Development :: Debuggers', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: System :: Monitoring', - 'Topic :: System :: Networking', - 'Topic :: System :: Networking :: Firewalls', - 'Topic :: System :: Networking :: Monitoring', - 'Topic :: Utilities', - 'Typing :: Typed', - ], -) diff --git a/proxy/common/flags.py b/proxy/common/flags.py index 7b12411de0..1041e89372 100644 --- a/proxy/common/flags.py +++ b/proxy/common/flags.py @@ -251,7 +251,7 @@ def initialize( args.enable_events)), plugins=Flags.load_plugins( bytes_( - '%s%s' % + '%s,%s' % (text_(COMMA).join(collections.OrderedDict(default_plugins).keys()), opts.get('plugins', args.plugins)))), pid_file=cast(Optional[str], opts.get('pid_file', args.pid_file))) diff --git a/proxy/plugin/__init__.py b/proxy/plugin/__init__.py new file mode 100644 index 0000000000..1a88565c6c --- /dev/null +++ b/proxy/plugin/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .cache_responses import CacheResponsesPlugin +from .filter_by_upstream import FilterByUpstreamHostPlugin +from .man_in_the_middle import ManInTheMiddlePlugin +from .mock_rest_api import ProposedRestApiPlugin +from .modify_post_data import ModifyPostDataPlugin +from .redirect_to_custom_server import RedirectToCustomServerPlugin +from .shortlink import ShortLinkPlugin +from .web_server_route import WebServerPlugin + +__all__ = [ + 'CacheResponsesPlugin', + 'FilterByUpstreamHostPlugin', + 'ManInTheMiddlePlugin', + 'ProposedRestApiPlugin', + 'ModifyPostDataPlugin', + 'RedirectToCustomServerPlugin', + 'ShortLinkPlugin', + 'WebServerPlugin', +] diff --git a/plugin_examples/cache_responses.py b/proxy/plugin/cache_responses.py similarity index 92% rename from plugin_examples/cache_responses.py rename to proxy/plugin/cache_responses.py index 63e5c61b10..d09cd1fa5f 100644 --- a/plugin_examples/cache_responses.py +++ b/proxy/plugin/cache_responses.py @@ -14,9 +14,9 @@ import logging from typing import Optional, BinaryIO, Any -from proxy.http.parser import HttpParser -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.common.utils import text_ +from ..common.utils import text_ +from ..http.parser import HttpParser +from ..http.proxy import HttpProxyBasePlugin logger = logging.getLogger(__name__) diff --git a/plugin_examples/filter_by_upstream.py b/proxy/plugin/filter_by_upstream.py similarity index 86% rename from plugin_examples/filter_by_upstream.py rename to proxy/plugin/filter_by_upstream.py index 5dbe0fd78e..0cbe01c0bf 100644 --- a/plugin_examples/filter_by_upstream.py +++ b/proxy/plugin/filter_by_upstream.py @@ -10,10 +10,10 @@ """ from typing import Optional -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.http.exception import HttpRequestRejected -from proxy.http.parser import HttpParser -from proxy.http.codes import httpStatusCodes +from ..http.exception import HttpRequestRejected +from ..http.parser import HttpParser +from ..http.codes import httpStatusCodes +from ..http.proxy import HttpProxyBasePlugin class FilterByUpstreamHostPlugin(HttpProxyBasePlugin): diff --git a/plugin_examples/man_in_the_middle.py b/proxy/plugin/man_in_the_middle.py similarity index 83% rename from plugin_examples/man_in_the_middle.py rename to proxy/plugin/man_in_the_middle.py index f083ce89e9..ec00fe4626 100644 --- a/plugin_examples/man_in_the_middle.py +++ b/proxy/plugin/man_in_the_middle.py @@ -10,10 +10,10 @@ """ from typing import Optional -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.http.parser import HttpParser -from proxy.http.codes import httpStatusCodes -from proxy.common.utils import build_http_response +from ..common.utils import build_http_response +from ..http.parser import HttpParser +from ..http.codes import httpStatusCodes +from ..http.proxy import HttpProxyBasePlugin class ManInTheMiddlePlugin(HttpProxyBasePlugin): diff --git a/plugin_examples/mock_rest_api.py b/proxy/plugin/mock_rest_api.py similarity index 92% rename from plugin_examples/mock_rest_api.py rename to proxy/plugin/mock_rest_api.py index 2eab7f15b4..234c7c0f88 100644 --- a/plugin_examples/mock_rest_api.py +++ b/proxy/plugin/mock_rest_api.py @@ -11,10 +11,10 @@ import json from typing import Optional -from proxy.http.parser import HttpParser -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.http.codes import httpStatusCodes -from proxy.common.utils import bytes_, build_http_response, text_ +from ..common.utils import bytes_, build_http_response, text_ +from ..http.parser import HttpParser +from ..http.proxy import HttpProxyBasePlugin +from ..http.codes import httpStatusCodes class ProposedRestApiPlugin(HttpProxyBasePlugin): diff --git a/plugin_examples/modify_post_data.py b/proxy/plugin/modify_post_data.py similarity index 89% rename from plugin_examples/modify_post_data.py rename to proxy/plugin/modify_post_data.py index 324742ec85..9b46f385f1 100644 --- a/plugin_examples/modify_post_data.py +++ b/proxy/plugin/modify_post_data.py @@ -10,10 +10,10 @@ """ from typing import Optional -from proxy.http.parser import HttpParser -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.http.methods import httpMethods -from proxy.common.utils import bytes_ +from ..common.utils import bytes_ +from ..http.parser import HttpParser +from ..http.proxy import HttpProxyBasePlugin +from ..http.methods import httpMethods class ModifyPostDataPlugin(HttpProxyBasePlugin): diff --git a/plugin_examples/redirect_to_custom_server.py b/proxy/plugin/redirect_to_custom_server.py similarity index 91% rename from plugin_examples/redirect_to_custom_server.py rename to proxy/plugin/redirect_to_custom_server.py index d93a2d85bb..7de292951d 100644 --- a/plugin_examples/redirect_to_custom_server.py +++ b/proxy/plugin/redirect_to_custom_server.py @@ -11,9 +11,9 @@ from urllib import parse as urlparse from typing import Optional -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.http.parser import HttpParser -from proxy.http.methods import httpMethods +from ..http.proxy import HttpProxyBasePlugin +from ..http.parser import HttpParser +from ..http.methods import httpMethods class RedirectToCustomServerPlugin(HttpProxyBasePlugin): diff --git a/plugin_examples/shortlink.py b/proxy/plugin/shortlink.py similarity index 91% rename from plugin_examples/shortlink.py rename to proxy/plugin/shortlink.py index 70d5b270b4..d5fead858d 100644 --- a/plugin_examples/shortlink.py +++ b/proxy/plugin/shortlink.py @@ -10,11 +10,11 @@ """ from typing import Optional -from proxy.http.proxy import HttpProxyBasePlugin -from proxy.http.parser import HttpParser -from proxy.http.codes import httpStatusCodes -from proxy.common.constants import DOT, SLASH -from proxy.common.utils import build_http_response +from ..common.constants import DOT, SLASH +from ..common.utils import build_http_response +from ..http.parser import HttpParser +from ..http.codes import httpStatusCodes +from ..http.proxy import HttpProxyBasePlugin class ShortLinkPlugin(HttpProxyBasePlugin): diff --git a/plugin_examples/web_server_route.py b/proxy/plugin/web_server_route.py similarity index 84% rename from plugin_examples/web_server_route.py rename to proxy/plugin/web_server_route.py index ffb4aa65c7..476221be87 100644 --- a/plugin_examples/web_server_route.py +++ b/proxy/plugin/web_server_route.py @@ -11,11 +11,11 @@ import logging from typing import List, Tuple -from proxy.http.server import HttpWebServerBasePlugin, httpProtocolTypes -from proxy.http.websocket import WebsocketFrame -from proxy.http.parser import HttpParser -from proxy.http.codes import httpStatusCodes -from proxy.common.utils import build_http_response +from ..common.utils import build_http_response +from ..http.parser import HttpParser +from ..http.codes import httpStatusCodes +from ..http.websocket import WebsocketFrame +from ..http.server import HttpWebServerBasePlugin, httpProtocolTypes logger = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index 5adf6d6481..4bc8620f7e 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,14 @@ long_description_content_type='text/markdown', download_url=__download_url__, license=__license__, - packages=['proxy', 'proxy.common', 'proxy.core', 'proxy.http'], + packages=[ + 'proxy', + 'proxy.common', + 'proxy.core', + 'proxy.dashboard', + 'proxy.http', + 'proxy.plugin' + ], install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ 'console_scripts': [ diff --git a/tests/http/test_http_proxy_examples.py b/tests/http/test_http_proxy_examples.py index b90eba2427..994e9059a0 100644 --- a/tests/http/test_http_proxy_examples.py +++ b/tests/http/test_http_proxy_examples.py @@ -22,8 +22,7 @@ from proxy.common.constants import PROXY_AGENT_HEADER_VALUE from proxy.http.codes import httpStatusCodes -from plugin_examples import mock_rest_api -from plugin_examples import redirect_to_custom_server +from proxy.plugin import ProposedRestApiPlugin, RedirectToCustomServerPlugin from .utils import get_plugin_by_test_name @@ -97,9 +96,9 @@ def test_proposed_rest_api_plugin( path = b'/v1/users/' self._conn.recv.return_value = build_http_request( b'GET', b'http://%s%s' % ( - mock_rest_api.ProposedRestApiPlugin.API_SERVER, path), + ProposedRestApiPlugin.API_SERVER, path), headers={ - b'Host': mock_rest_api.ProposedRestApiPlugin.API_SERVER, + b'Host': ProposedRestApiPlugin.API_SERVER, } ) self.mock_selector.return_value.select.side_effect = [ @@ -118,7 +117,7 @@ def test_proposed_rest_api_plugin( headers={b'Content-Type': b'application/json'}, body=bytes_( json.dumps( - mock_rest_api.ProposedRestApiPlugin.REST_API_SPEC[path])) + ProposedRestApiPlugin.REST_API_SPEC[path])) )) @mock.patch('proxy.http.proxy.TcpServerConnection') @@ -140,7 +139,7 @@ def test_redirect_to_custom_server_plugin( self.protocol_handler.run_once() upstream = urlparse.urlsplit( - redirect_to_custom_server.RedirectToCustomServerPlugin.UPSTREAM_SERVER) + RedirectToCustomServerPlugin.UPSTREAM_SERVER) mock_server_conn.assert_called_with('localhost', 8899) mock_server_conn.return_value.queue.assert_called_with( build_http_request( diff --git a/tests/http/utils.py b/tests/http/utils.py index ccd578af4a..3d0e3f953a 100644 --- a/tests/http/utils.py +++ b/tests/http/utils.py @@ -1,26 +1,22 @@ from typing import Type from proxy.http.proxy import HttpProxyBasePlugin -from plugin_examples import modify_post_data -from plugin_examples import mock_rest_api -from plugin_examples import redirect_to_custom_server -from plugin_examples import filter_by_upstream -from plugin_examples import cache_responses -from plugin_examples import man_in_the_middle +from proxy.plugin import ModifyPostDataPlugin, ProposedRestApiPlugin, RedirectToCustomServerPlugin, \ + FilterByUpstreamHostPlugin, CacheResponsesPlugin, ManInTheMiddlePlugin def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]: - plugin: Type[HttpProxyBasePlugin] = modify_post_data.ModifyPostDataPlugin + plugin: Type[HttpProxyBasePlugin] = ModifyPostDataPlugin if test_name == 'test_modify_post_data_plugin': - plugin = modify_post_data.ModifyPostDataPlugin + plugin = ModifyPostDataPlugin elif test_name == 'test_proposed_rest_api_plugin': - plugin = mock_rest_api.ProposedRestApiPlugin + plugin = ProposedRestApiPlugin elif test_name == 'test_redirect_to_custom_server_plugin': - plugin = redirect_to_custom_server.RedirectToCustomServerPlugin + plugin = RedirectToCustomServerPlugin elif test_name == 'test_filter_by_upstream_host_plugin': - plugin = filter_by_upstream.FilterByUpstreamHostPlugin + plugin = FilterByUpstreamHostPlugin elif test_name == 'test_cache_responses_plugin': - plugin = cache_responses.CacheResponsesPlugin + plugin = CacheResponsesPlugin elif test_name == 'test_man_in_the_middle_plugin': - plugin = man_in_the_middle.ManInTheMiddlePlugin + plugin = ManInTheMiddlePlugin return plugin From bb7006b67ac7b9a4c44417b05b1dea0c8942cc33 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 15 Nov 2019 23:49:51 -0800 Subject: [PATCH 039/107] Move benchmark module within proxy (#181) * Move benchmark within proxy module * chmod 0644 for benchmark.py which was executable till now * Turn utilities into its own section --- .github/workflows/test-library.yml | 4 +-- Makefile | 5 ++- README.md | 35 +++++++++--------- {benchmark => proxy/benchmark}/__init__.py | 0 {benchmark => proxy/benchmark}/benchmark.py | 40 ++++----------------- setup.py | 1 + 6 files changed, 30 insertions(+), 55 deletions(-) rename {benchmark => proxy/benchmark}/__init__.py (100%) rename {benchmark => proxy/benchmark}/benchmark.py (76%) mode change 100755 => 100644 diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 6b957fc242..459aebbc88 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -25,8 +25,8 @@ jobs: pip install -r requirements-testing.txt - name: Quality Check run: | - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ setup.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ setup.py - name: Run Tests run: pytest --cov=proxy tests/ - name: Upload coverage to Codecov diff --git a/Makefile b/Makefile index 5f0ac0415a..8a6c69eff8 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,6 @@ autopep8: autopep8 --recursive --in-place --aggressive proxy/*.py autopep8 --recursive --in-place --aggressive proxy/*/*.py autopep8 --recursive --in-place --aggressive tests/*.py - autopep8 --recursive --in-place --aggressive benchmark/*.py autopep8 --recursive --in-place --aggressive setup.py https-certificates: @@ -58,8 +57,8 @@ lib-clean: rm -rf .hypothesis lib-lint: - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ benchmark/ setup.py - mypy --strict --ignore-missing-imports proxy/ tests/ benchmark/ setup.py + flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ setup.py + mypy --strict --ignore-missing-imports proxy/ tests/ setup.py lib-test: lib-lint python -m unittest discover diff --git a/README.md b/README.md index b96c71364f..dc3fcecb3e 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,16 @@ Table of Contents * [Setup Local Environment](#setup-local-environment) * [Setup pre-commit hook](#setup-pre-commit-hook) * [Sending a Pull Request](#sending-a-pull-request) - * [Utilities](#utilities) - * [TCP](#tcp-sockets) - * [new_socket_connection](#new_socket_connection) - * [socket_connection](#socket_connection) - * [Http](#http-client) - * [build_http_request](#build_http_request) - * [build_http_response](#build_http_response) - * [Websocket](#websocket) - * [WebsocketFrame](#websocketframe) - * [WebsocketClient](#websocketclient) +* [Utilities](#utilities) + * [TCP](#tcp-sockets) + * [new_socket_connection](#new_socket_connection) + * [socket_connection](#socket_connection) + * [Http](#http-client) + * [build_http_request](#build_http_request) + * [build_http_response](#build_http_response) + * [Websocket](#websocket) + * [WebsocketFrame](#websocketframe) + * [WebsocketClient](#websocketclient) * [Frequently Asked Questions](#frequently-asked-questions) * [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax) * [Unable to load plugins](#unable-to-load-plugins) @@ -915,25 +915,28 @@ and invoke `HttpProxyBasePlugin` lifecycle hooks. ## Development Guide -#### Setup Local Environment +### Setup Local Environment Contributors must start `proxy.py` from source to verify and develop new features / fixes. + See [Run proxy.py from command line using repo source](#from-command-line-using-repo-source) for details. -#### Setup pre-commit hook +### Setup pre-commit hook + +Pre-commit hook ensures lint checking and tests execution. 1. `cd /path/to/proxy.py` 2. `ln -s $(PWD)/git-pre-commit .git/hooks/pre-commit` -Pre-commit hook ensures lint checking and library tests passes. - -#### Sending a Pull Request +### Sending a Pull Request Every pull request is tested using GitHub actions. + See [GitHub workflow](https://github.com/abhinavsingh/proxy.py/tree/develop/.github/workflows) for list of tests. -## Utilities +Utilities +========= ## TCP Sockets diff --git a/benchmark/__init__.py b/proxy/benchmark/__init__.py similarity index 100% rename from benchmark/__init__.py rename to proxy/benchmark/__init__.py diff --git a/benchmark/benchmark.py b/proxy/benchmark/benchmark.py old mode 100755 new mode 100644 similarity index 76% rename from benchmark/benchmark.py rename to proxy/benchmark/benchmark.py index 4ee78cf5b5..2942a15a8c --- a/benchmark/benchmark.py +++ b/proxy/benchmark/benchmark.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ proxy.py @@ -9,42 +8,20 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -import argparse import asyncio -import sys import time from typing import List, Tuple -from proxy.common.constants import DEFAULT_BUFFER_SIZE -from proxy.common.utils import build_http_request -from proxy.http.methods import httpMethods -from proxy.http.parser import httpParserStates, httpParserTypes, HttpParser +from ..common.constants import DEFAULT_BUFFER_SIZE +from ..common.utils import build_http_request +from ..http.methods import httpMethods +from ..http.parser import httpParserStates, httpParserTypes, HttpParser __homepage__ = 'https://github.com/abhinavsingh/proxy.py' DEFAULT_N = 1 -def init_parser() -> argparse.ArgumentParser: - """Initializes and returns argument parser.""" - parser = argparse.ArgumentParser( - description='Benchmark opens N concurrent connections ' - 'to proxy.py web server. Currently, HTTP/1.1 ' - 'keep-alive connections are opened. Over each opened ' - 'connection multiple pipelined request / response ' - 'packets are exchanged with proxy.py web server.', - epilog='Proxy.py not working? Report at: %s/issues/new' % __homepage__ - ) - parser.add_argument( - '--n', '-n', - type=int, - default=DEFAULT_N, - help='Default: ' + str(DEFAULT_N) + - '. See description above for meaning of N.' - ) - return parser - - class Benchmark: def __init__(self, n: int = DEFAULT_N) -> None: @@ -143,14 +120,9 @@ async def run(self) -> None: pass -def main(input_args: List[str]) -> None: - args = init_parser().parse_args(input_args) - benchmark = Benchmark(n=args.n) +def main() -> None: + benchmark = Benchmark(n=DEFAULT_N) try: asyncio.run(benchmark.run()) except KeyboardInterrupt: pass - - -if __name__ == '__main__': - main(sys.argv[1:]) # pragma: no cover diff --git a/setup.py b/setup.py index 4bc8620f7e..f1cba442ba 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ license=__license__, packages=[ 'proxy', + 'proxy.benchmark', 'proxy.common', 'proxy.core', 'proxy.dashboard', From 6aed91255428e809c33cac8750b40dabca195714 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 16 Nov 2019 09:50:42 +0200 Subject: [PATCH 040/107] Update pytest from 5.2.3 to 5.2.4 (#180) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 16cff962bc..7623952a76 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,7 +1,7 @@ python-coveralls==2.9.3 coverage==4.5.4 flake8==3.7.9 -pytest==5.2.3 +pytest==5.2.4 pytest-cov==2.8.1 autopep8==1.4.4 mypy==0.740 From ad42e0d74df4917b70c2cac3249e0133a3f197d2 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 18 Nov 2019 20:45:51 -0800 Subject: [PATCH 041/107] Doc & Banner update to match GitHub (#182) * Update doc and banner * Update banner to match GitHub * Update older banners too * Add update_desc to .gitignore * Update banner for dashboard to match github * also update html, js, css --- README.md | 17 +++++++++++++++-- dashboard/src/core/devtools.ts | 4 ++-- dashboard/src/core/plugin.ts | 4 ++-- dashboard/src/core/plugins/home.ts | 4 ++-- dashboard/src/core/plugins/inspect_traffic.html | 4 ++-- dashboard/src/core/plugins/inspect_traffic.js | 4 ++-- dashboard/src/core/plugins/inspect_traffic.ts | 4 ++-- dashboard/src/core/plugins/settings.ts | 4 ++-- dashboard/src/core/ws.ts | 4 ++-- dashboard/src/plugins/mock_rest_api.ts | 4 ++-- dashboard/src/plugins/shortlink.ts | 4 ++-- dashboard/src/plugins/traffic_control.ts | 4 ++-- dashboard/src/proxy.css | 6 +++--- dashboard/src/proxy.html | 7 ++++--- dashboard/src/proxy.ts | 4 ++-- dashboard/test/test.ts | 4 ++-- helper/.gitignore | 1 + proxy/__init__.py | 3 ++- proxy/__main__.py | 3 ++- proxy/benchmark/__init__.py | 4 ++-- proxy/benchmark/benchmark.py | 4 ++-- proxy/common/__init__.py | 3 ++- proxy/common/constants.py | 3 ++- proxy/common/flags.py | 3 ++- proxy/common/pki.py | 3 ++- proxy/common/types.py | 3 ++- proxy/common/utils.py | 3 ++- proxy/common/version.py | 3 ++- proxy/core/__init__.py | 3 ++- proxy/core/acceptor.py | 3 ++- proxy/core/connection.py | 3 ++- proxy/core/event.py | 3 ++- proxy/core/threadless.py | 3 ++- proxy/dashboard/__init__.py | 3 ++- proxy/dashboard/dashboard.py | 4 ++-- proxy/dashboard/inspect_traffic.py | 10 ++++++++++ proxy/dashboard/plugin.py | 3 ++- proxy/http/__init__.py | 3 ++- proxy/http/chunk_parser.py | 3 ++- proxy/http/codes.py | 3 ++- proxy/http/exception.py | 3 ++- proxy/http/handler.py | 3 ++- proxy/http/inspector.py | 12 ++++++++++-- proxy/http/methods.py | 3 ++- proxy/http/parser.py | 3 ++- proxy/http/proxy.py | 3 ++- proxy/http/server.py | 3 ++- proxy/http/websocket.py | 3 ++- proxy/plugin/__init__.py | 4 ++-- proxy/plugin/cache_responses.py | 4 ++-- proxy/plugin/filter_by_upstream.py | 4 ++-- proxy/plugin/man_in_the_middle.py | 4 ++-- proxy/plugin/mock_rest_api.py | 4 ++-- proxy/plugin/modify_post_data.py | 4 ++-- proxy/plugin/redirect_to_custom_server.py | 4 ++-- proxy/plugin/shortlink.py | 4 ++-- proxy/plugin/web_server_route.py | 4 ++-- proxy/proxy.py | 3 ++- 58 files changed, 147 insertions(+), 87 deletions(-) create mode 100644 helper/.gitignore diff --git a/README.md b/README.md index dc3fcecb3e..7792731cca 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Table of Contents * [Start proxy.py](#start-proxypy) * [From command line when installed using PIP](#from-command-line-when-installed-using-pip) * [Run it](#run-it) - * [Reading logs](#reading-logs) + * [Understanding logs](#understanding-logs) * [Enable DEBUG logging](#enable-debug-logging) * [From command line using repo source](#from-command-line-using-repo-source) * [Docker Image](#docker-image) @@ -86,6 +86,7 @@ Table of Contents * [Basic auth not working with a browser](#basic-auth-not-working-with-a-browser) * [Docker image not working on MacOS](#docker-image-not-working-on-macos) * [ValueError: filedescriptor out of range in select](#valueerror-filedescriptor-out-of-range-in-select) + * [None:None in access logs](#nonenone-in-access-logs) * [Flags](#flags) * [Changelog](#changelog) * [v2.x](#v2x) @@ -188,7 +189,7 @@ $ proxy ...[redacted]... - Started server on ::1:8899 ``` -#### Reading logs +#### Understanding logs Things to notice from above logs: @@ -1119,6 +1120,18 @@ with `requests per second` sent and output of following debug script: $ ./helper/monitor_open_files.sh ``` +## None:None in access logs + +Sometimes you may see `None:None` in access logs. It simply means +that an upstream server connection was never established i.e. +`upstream_host=None`, `upstream_port=None`. + +There can be several reasons for no upstream connection, +few obvious ones include: + +1. Client established a connection but never completed the request. +2. A plugin returned a response prematurely, avoiding connection to upstream server. + Flags ===== diff --git a/dashboard/src/core/devtools.ts b/dashboard/src/core/devtools.ts index f231123781..ca33c59c58 100644 --- a/dashboard/src/core/devtools.ts +++ b/dashboard/src/core/devtools.ts @@ -1,8 +1,8 @@ /* proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/src/core/plugin.ts b/dashboard/src/core/plugin.ts index d384cbc6d8..ad51abc693 100644 --- a/dashboard/src/core/plugin.ts +++ b/dashboard/src/core/plugin.ts @@ -1,8 +1,8 @@ /* proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/src/core/plugins/home.ts b/dashboard/src/core/plugins/home.ts index 3b542ced1e..3a840d297f 100644 --- a/dashboard/src/core/plugins/home.ts +++ b/dashboard/src/core/plugins/home.ts @@ -1,8 +1,8 @@ /* proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/dashboard/src/core/plugins/inspect_traffic.html b/dashboard/src/core/plugins/inspect_traffic.html index 18e8689265..3889c73b78 100644 --- a/dashboard/src/core/plugins/inspect_traffic.html +++ b/dashboard/src/core/plugins/inspect_traffic.html @@ -1,8 +1,8 @@ ' + destinationFolderPath) + console.log('Destination folder: ' + destinationFolderPath) ncp(chromeDevTools, destinationFolderPath, (err: any) => { if (err) { return console.error(err) } - console.log('Copy successful!!!') + console.log('Done!!!') }) } diff --git a/dashboard/src/core/plugin.ts b/dashboard/src/core/plugin.ts index ad51abc693..ead2c59cc9 100644 --- a/dashboard/src/core/plugin.ts +++ b/dashboard/src/core/plugin.ts @@ -25,6 +25,14 @@ export interface IPluginConstructor { } export abstract class DashboardPlugin implements IDashboardPlugin { + public abstract readonly name: string + public abstract readonly title: string + public abstract initializeTab() : JQuery + public abstract initializeHeader(): JQuery + public abstract initializeBody(): JQuery + public abstract activated(): void + public abstract deactivated(): void + protected websocketApi: WebsocketApi public constructor (websocketApi: WebsocketApi) { @@ -69,12 +77,4 @@ export abstract class DashboardPlugin implements IDashboardPlugin { ) ) } - - public abstract readonly name: string - public abstract readonly title: string - public abstract initializeTab() : JQuery - public abstract initializeHeader(): JQuery - public abstract initializeBody(): JQuery - public abstract activated(): void - public abstract deactivated(): void } diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts index 322d480304..b6c527d409 100644 --- a/dashboard/src/proxy.ts +++ b/dashboard/src/proxy.ts @@ -7,7 +7,6 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. */ - import { WebsocketApi } from './core/ws' import { IDashboardPlugin, IPluginConstructor } from './core/plugin' @@ -23,6 +22,7 @@ export class ProxyDashboard { private static plugins: IPluginConstructor[] = []; private plugins: Map = new Map(); private readonly websocketApi: WebsocketApi + private readonly route: string = '/dashboard/' constructor () { this.websocketApi = new WebsocketApi() @@ -91,15 +91,19 @@ export class ProxyDashboard { } this.navigate(activeTabPluginName, clickedTabPluginName) - window.history.pushState(null, null, '/dashboard/#' + clickedTabPluginName) + window.history.pushState(null, null, this.route + '#' + clickedTabPluginName) } private navigate (activeTabPluginName: string, clickedTabPluginName: string) { console.log('Navigating from', activeTabPluginName, 'to', clickedTabPluginName) if (activeTabPluginName !== undefined) { - $('#' + this.plugins.get(activeTabPluginName).tabId()).parent('li').removeClass('active') + $('#' + this.plugins.get(activeTabPluginName).tabId()) + .parent('li') + .removeClass('active') } - $('#' + this.plugins.get(clickedTabPluginName).tabId()).parent('li').addClass('active') + $('#' + this.plugins.get(clickedTabPluginName).tabId()) + .parent('li') + .addClass('active') $('#proxyDashboard>.proxy-dashboard-plugin').hide() $('#' + clickedTabPluginName).show() @@ -111,6 +115,8 @@ export class ProxyDashboard { } } +// TODO: Decouple plugin scripts from proxy.ts +// Plugin scripts must load independently ProxyDashboard.addPlugin(HomePlugin) ProxyDashboard.addPlugin(MockRestApiPlugin) ProxyDashboard.addPlugin(InspectTrafficPlugin) diff --git a/dashboard/static/js.cookie-3.0.0-beta.0.min.js b/dashboard/static/js.cookie-3.0.0-beta.0.min.js deleted file mode 100644 index f8d6c2dad5..0000000000 --- a/dashboard/static/js.cookie-3.0.0-beta.0.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! js-cookie v3.0.0-beta.0 | MIT */ -!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self,function(){var t=e.Cookies,o=e.Cookies=n();o.noConflict=function(){return e.Cookies=t,o}}())}(this,function(){"use strict";function e(){for(var e={},n=0;n -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY -// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION -// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -// Usage -// ----- -// The module exports one entry point, the `renderjson()` function. It takes in -// the JSON you want to render as a single argument and returns an HTML -// element. -// -// Options -// ------- -// renderjson.set_icons("+", "-") -// This Allows you to override the disclosure icons. -// -// renderjson.set_show_to_level(level) -// Pass the number of levels to expand when rendering. The default is 0, which -// starts with everything collapsed. As a special case, if level is the string -// "all" then it will start with everything expanded. -// -// renderjson.set_max_string_length(length) -// Strings will be truncated and made expandable if they are longer than -// `length`. As a special case, if `length` is the string "none" then -// there will be no truncation. The default is "none". -// -// renderjson.set_sort_objects(sort_bool) -// Sort objects by key (default: false) -// -// Theming -// ------- -// The HTML output uses a number of classes so that you can theme it the way -// you'd like: -// .disclosure ("⊕", "⊖") -// .syntax (",", ":", "{", "}", "[", "]") -// .string (includes quotes) -// .number -// .boolean -// .key (object key) -// .keyword ("null", "undefined") -// .object.syntax ("{", "}") -// .array.syntax ("[", "]") - -var module; -(module||{}).exports = renderjson = (function() { - var themetext = function(/* [class, text]+ */) { - var spans = []; - while (arguments.length) - spans.push(append(span(Array.prototype.shift.call(arguments)), - text(Array.prototype.shift.call(arguments)))); - return spans; - }; - var append = function(/* el, ... */) { - var el = Array.prototype.shift.call(arguments); - for (var a=0; a 0) - show(); - return el; - }; - - if (json === null) return themetext(null, my_indent, "keyword", "null"); - if (json === void 0) return themetext(null, my_indent, "keyword", "undefined"); - - if (typeof(json) == "string" && json.length > max_string) - return disclosure('"', json.substr(0,max_string)+" ...", '"', "string", function () { - return append(span("string"), themetext(null, my_indent, "string", JSON.stringify(json))); - }); - - if (typeof(json) != "object") // Strings, numbers and bools - return themetext(null, my_indent, typeof(json), JSON.stringify(json)); - - if (json.constructor == Array) { - if (json.length == 0) return themetext(null, my_indent, "array syntax", "[]"); - - return disclosure("[", " ... ", "]", "array", function () { - var as = append(span("array"), themetext("array syntax", "[", null, "\n")); - for (var i=0; i Dict[bytes, List[type]]: module_name.replace( os.path.sep, text_(DOT))), klass_name) - base_klass = inspect.getmro(klass)[1] + mro = list(inspect.getmro(klass)) + mro.reverse() + iterator = iter(mro) + while next(iterator) is not abc.ABC: + pass + base_klass = next(iterator) p[bytes_(base_klass.__name__)].append(klass) logger.info( 'Loaded %s %s.%s', diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor.py index 90d256b2d1..0c7afce410 100644 --- a/proxy/core/acceptor.py +++ b/proxy/core/acceptor.py @@ -153,7 +153,7 @@ def __init__( self.running = multiprocessing.Event() self.selector: Optional[selectors.DefaultSelector] = None self.sock: Optional[socket.socket] = None - self.threadless_process: Optional[multiprocessing.Process] = None + self.threadless_process: Optional[Threadless] = None self.threadless_client_queue: Optional[connection.Connection] = None def start_threadless_process(self) -> None: @@ -171,6 +171,7 @@ def start_threadless_process(self) -> None: def shutdown_threadless_process(self) -> None: assert self.threadless_process and self.threadless_client_queue logger.debug('Stopped process %d', self.threadless_process.pid) + self.threadless_process.running.set() self.threadless_process.join() self.threadless_client_queue.close() diff --git a/proxy/core/threadless.py b/proxy/core/threadless.py index 6d17f81704..750588906f 100644 --- a/proxy/core/threadless.py +++ b/proxy/core/threadless.py @@ -121,6 +121,7 @@ def __init__( self.work_klass = work_klass self.event_queue = event_queue + self.running = multiprocessing.Event() self.works: Dict[int, ThreadlessWork] = {} self.selector: Optional[selectors.DefaultSelector] = None self.loop: Optional[asyncio.AbstractEventLoop] = None @@ -232,7 +233,7 @@ def run(self) -> None: self.selector = selectors.DefaultSelector() self.selector.register(self.client_queue, selectors.EVENT_READ) self.loop = asyncio.get_event_loop() - while True: + while not self.running.is_set(): self.run_once() except KeyboardInterrupt: pass diff --git a/proxy/dashboard/dashboard.py b/proxy/dashboard/dashboard.py index 165c3093ae..23d58a3786 100644 --- a/proxy/dashboard/dashboard.py +++ b/proxy/dashboard/dashboard.py @@ -26,6 +26,25 @@ class ProxyDashboard(HttpWebServerBasePlugin): """Proxy Dashboard.""" + # Redirects to /dashboard/ + REDIRECT_ROUTES = [ + (httpProtocolTypes.HTTP, b'/dashboard'), + (httpProtocolTypes.HTTPS, b'/dashboard'), + (httpProtocolTypes.HTTP, b'/dashboard/proxy.html'), + (httpProtocolTypes.HTTPS, b'/dashboard/proxy.html'), + ] + + # Index html route + INDEX_ROUTES = [ + (httpProtocolTypes.HTTP, b'/dashboard/'), + (httpProtocolTypes.HTTPS, b'/dashboard/'), + ] + + # Handles WebsocketAPI requests for dashboard + WS_ROUTES = [ + (httpProtocolTypes.WEBSOCKET, b'/dashboard'), + ] + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.plugins: Dict[str, ProxyDashboardWebsocketPlugin] = {} @@ -36,19 +55,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.plugins[method] = p def routes(self) -> List[Tuple[int, bytes]]: - return [ - # Redirects to /dashboard/ - (httpProtocolTypes.HTTP, b'/dashboard'), - # Redirects to /dashboard/ - (httpProtocolTypes.HTTPS, b'/dashboard'), - # Redirects to /dashboard/ - (httpProtocolTypes.HTTP, b'/dashboard/proxy.html'), - # Redirects to /dashboard/ - (httpProtocolTypes.HTTPS, b'/dashboard/proxy.html'), - (httpProtocolTypes.HTTP, b'/dashboard/'), - (httpProtocolTypes.HTTPS, b'/dashboard/'), - (httpProtocolTypes.WEBSOCKET, b'/dashboard'), - ] + return ProxyDashboard.REDIRECT_ROUTES + \ + ProxyDashboard.INDEX_ROUTES + \ + ProxyDashboard.WS_ROUTES def handle_request(self, request: HttpParser) -> None: if request.path == b'/dashboard/': diff --git a/proxy/http/proxy.py b/proxy/http/proxy.py index a550061e3d..9dfe836e4a 100644 --- a/proxy/http/proxy.py +++ b/proxy/http/proxy.py @@ -46,7 +46,7 @@ def __init__( uid: str, flags: Flags, client: TcpClientConnection, - event_queue: EventQueue): + event_queue: EventQueue) -> None: self.uid = uid # pragma: no cover self.flags = flags # pragma: no cover self.client = client # pragma: no cover diff --git a/proxy/plugin/__init__.py b/proxy/plugin/__init__.py index f71152bc5d..480c74d3e5 100644 --- a/proxy/plugin/__init__.py +++ b/proxy/plugin/__init__.py @@ -8,7 +8,7 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from .cache_responses import CacheResponsesPlugin +from .cache import CacheResponsesPlugin, BaseCacheResponsesPlugin from .filter_by_upstream import FilterByUpstreamHostPlugin from .man_in_the_middle import ManInTheMiddlePlugin from .mock_rest_api import ProposedRestApiPlugin @@ -19,6 +19,7 @@ __all__ = [ 'CacheResponsesPlugin', + 'BaseCacheResponsesPlugin', 'FilterByUpstreamHostPlugin', 'ManInTheMiddlePlugin', 'ProposedRestApiPlugin', diff --git a/proxy/plugin/cache/__init__.py b/proxy/plugin/cache/__init__.py new file mode 100644 index 0000000000..f3bfb84b2c --- /dev/null +++ b/proxy/plugin/cache/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .base import BaseCacheResponsesPlugin +from .cache_responses import CacheResponsesPlugin + +__all__ = [ + 'BaseCacheResponsesPlugin', + 'CacheResponsesPlugin', +] diff --git a/proxy/plugin/cache/base.py b/proxy/plugin/cache/base.py new file mode 100644 index 0000000000..5a9f5fed8a --- /dev/null +++ b/proxy/plugin/cache/base.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging +from typing import Optional, Any + +from ...http.parser import HttpParser +from ...http.proxy import HttpProxyBasePlugin +from .store.base import CacheStore + +logger = logging.getLogger(__name__) + + +class BaseCacheResponsesPlugin(HttpProxyBasePlugin): + """Base cache plugin. + + It requires a storage backend to work with. Storage class + must implement CacheStore interface. + + Different storage backends can be used per request if required. + """ + + def __init__( + self, + *args: Any, + **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.store: Optional[CacheStore] = None + + def set_store(self, store: CacheStore) -> None: + self.store = store + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + assert self.store + try: + self.store.open(request) + except Exception as e: + logger.info('Caching disabled due to exception message %s', str(e)) + return request + + def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: + assert self.store + return self.store.cache_request(request) + + def handle_upstream_chunk(self, chunk: bytes) -> bytes: + assert self.store + return self.store.cache_response_chunk(chunk) + + def on_upstream_connection_close(self) -> None: + assert self.store + self.store.close() diff --git a/proxy/plugin/cache/cache_responses.py b/proxy/plugin/cache/cache_responses.py new file mode 100644 index 0000000000..2ad6926d26 --- /dev/null +++ b/proxy/plugin/cache/cache_responses.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import multiprocessing +import tempfile +from typing import Any + +from .store.disk import OnDiskCacheStore +from .base import BaseCacheResponsesPlugin + + +class CacheResponsesPlugin(BaseCacheResponsesPlugin): + """Caches response using OnDiskCacheStore.""" + + # Dynamically enable / disable cache + ENABLED = multiprocessing.Event() + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.disk_store = OnDiskCacheStore(uid=self.uid, cache_dir=tempfile.gettempdir()) + self.set_store(self.disk_store) diff --git a/proxy/plugin/cache/store/__init__.py b/proxy/plugin/cache/store/__init__.py new file mode 100644 index 0000000000..232621f0b5 --- /dev/null +++ b/proxy/plugin/cache/store/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/plugin/cache/store/base.py b/proxy/plugin/cache/store/base.py new file mode 100644 index 0000000000..e382a96ab0 --- /dev/null +++ b/proxy/plugin/cache/store/base.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from abc import ABC, abstractmethod +from typing import Optional + +from ....http.parser import HttpParser + + +class CacheStore(ABC): + + def __init__(self, uid: str) -> None: + self.uid = uid + + @abstractmethod + def open(self, request: HttpParser) -> None: + pass + + @abstractmethod + def cache_request(self, request: HttpParser) -> Optional[HttpParser]: + return request + + @abstractmethod + def cache_response_chunk(self, chunk: bytes) -> bytes: + return chunk + + @abstractmethod + def close(self) -> None: + pass diff --git a/proxy/plugin/cache/store/disk.py b/proxy/plugin/cache/store/disk.py new file mode 100644 index 0000000000..3de1b073c4 --- /dev/null +++ b/proxy/plugin/cache/store/disk.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging +import os +from typing import Optional, BinaryIO + +from ....common.utils import text_ +from ....http.parser import HttpParser + +from .base import CacheStore + +logger = logging.getLogger(__name__) + + +class OnDiskCacheStore(CacheStore): + + def __init__(self, uid: str, cache_dir: str) -> None: + super().__init__(uid) + self.cache_dir = cache_dir + self.cache_file_path: Optional[str] = None + self.cache_file: Optional[BinaryIO] = None + + def open(self, request: HttpParser) -> None: + self.cache_file_path = os.path.join( + self.cache_dir, + '%s-%s.txt' % (text_(request.host), self.uid)) + self.cache_file = open(self.cache_file_path, "wb") + + def cache_request(self, request: HttpParser) -> Optional[HttpParser]: + return request + + def cache_response_chunk(self, chunk: bytes) -> bytes: + if self.cache_file: + self.cache_file.write(chunk) + return chunk + + def close(self) -> None: + if self.cache_file: + self.cache_file.close() + logger.info('Cached response at %s', self.cache_file_path) diff --git a/proxy/plugin/cache_responses.py b/proxy/plugin/cache_responses.py deleted file mode 100644 index 235f76952c..0000000000 --- a/proxy/plugin/cache_responses.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on - Network monitoring, controls & Application development, testing, debugging. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import os -import tempfile -import time -import logging -from typing import Optional, BinaryIO, Any - -from ..common.utils import text_ -from ..http.parser import HttpParser -from ..http.proxy import HttpProxyBasePlugin - -logger = logging.getLogger(__name__) - - -class CacheResponsesPlugin(HttpProxyBasePlugin): - """Caches Upstream Server Responses.""" - - CACHE_DIR = tempfile.gettempdir() - - def __init__( - self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.cache_file_path: Optional[str] = None - self.cache_file: Optional[BinaryIO] = None - - def before_upstream_connection( - self, request: HttpParser) -> Optional[HttpParser]: - # Ideally should only create file if upstream connection succeeds. - self.cache_file_path = os.path.join( - self.CACHE_DIR, - '%s-%s.txt' % (text_(request.host), str(time.time()))) - self.cache_file = open(self.cache_file_path, "wb") - return request - - def handle_client_request( - self, request: HttpParser) -> Optional[HttpParser]: - return request - - def handle_upstream_chunk(self, - chunk: bytes) -> bytes: - if self.cache_file: - self.cache_file.write(chunk) - return chunk - - def on_upstream_connection_close(self) -> None: - if self.cache_file: - self.cache_file.close() - logger.info('Cached response at %s', self.cache_file_path) diff --git a/proxy/proxy.py b/proxy/proxy.py index 392fa954c2..5479032689 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -12,12 +12,12 @@ import os import sys import time -import unittest import logging -from typing import List, Optional, Generator, Any +from types import TracebackType +from typing import List, Optional, Generator, Any, Type -from .common.utils import bytes_, get_available_port, new_socket_connection +from .common.utils import bytes_ from .common.flags import Flags from .core.acceptor import AcceptorPool from .http.handler import HttpProtocolHandler @@ -25,70 +25,64 @@ logger = logging.getLogger(__name__) -class TestCase(unittest.TestCase): - """Base TestCase class that automatically setup and teardown proxy.py.""" +class Proxy: - DEFAULT_PROXY_PY_STARTUP_FLAGS = [ - '--num-workers', '1', - ] + def __init__(self, input_args: Optional[List[str]], **opts: Any) -> None: + self.flags = Flags.initialize(input_args, **opts) + self.acceptors: Optional[AcceptorPool] = None - def run(self, result: Optional[unittest.TestResult] = None) -> Any: - self.proxy_port = get_available_port() + def write_pid_file(self) -> None: + if self.flags.pid_file is not None: + with open(self.flags.pid_file, 'wb') as pid_file: + pid_file.write(bytes_(os.getpid())) - flags = getattr(self, 'PROXY_PY_STARTUP_FLAGS') \ - if hasattr(self, 'PROXY_PY_STARTUP_FLAGS') \ - else self.DEFAULT_PROXY_PY_STARTUP_FLAGS - flags.append('--port') - flags.append(str(self.proxy_port)) + def delete_pid_file(self) -> None: + if self.flags.pid_file and os.path.exists(self.flags.pid_file): + os.remove(self.flags.pid_file) - with start(flags): - # Wait for proxy.py server to come up - while True: - try: - conn = new_socket_connection( - ('localhost', self.proxy_port)) - conn.close() - break - except ConnectionRefusedError: - time.sleep(0.1) - # Run tests - super().run(result) + def __enter__(self) -> 'Proxy': + self.acceptors = AcceptorPool( + flags=self.flags, + work_klass=HttpProtocolHandler + ) + self.acceptors.setup() + self.write_pid_file() + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType]) -> None: + assert self.acceptors + self.acceptors.shutdown() + self.delete_pid_file() @contextlib.contextmanager def start( input_args: Optional[List[str]] = None, - **opts: Any) -> Generator[None, None, None]: - flags = Flags.initialize(input_args, **opts) + **opts: Any) -> Generator[Proxy, None, None]: + """Deprecated. Kept for backward compatibility. + + New users must directly use proxy.Proxy context manager class.""" try: - acceptor_pool = AcceptorPool( - flags=flags, - work_klass=HttpProtocolHandler - ) - if flags.pid_file is not None: - with open(flags.pid_file, 'wb') as pid_file: - pid_file.write(bytes_(os.getpid())) - try: - acceptor_pool.setup() - yield - except Exception as e: - logger.exception('exception', exc_info=e) - finally: - acceptor_pool.shutdown() - except KeyboardInterrupt: # pragma: no cover + with Proxy(input_args, **opts) as p: + yield p + except KeyboardInterrupt: pass - finally: - if flags.pid_file and os.path.exists(flags.pid_file): - os.remove(flags.pid_file) def main( input_args: Optional[List[str]] = None, **opts: Any) -> None: - with start(input_args=input_args, **opts): - # TODO: Introduce cron feature instead of mindless sleep - while True: - time.sleep(1) + try: + with Proxy(input_args=input_args, **opts): + # TODO: Introduce cron feature instead of mindless sleep + while True: + time.sleep(1) + except KeyboardInterrupt: + pass def entry_point() -> None: diff --git a/proxy/testing/__init__.py b/proxy/testing/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/proxy/testing/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py new file mode 100644 index 0000000000..8f5f5a1dbe --- /dev/null +++ b/proxy/testing/test_case.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import contextlib +import time +import unittest +from typing import Optional, List, Generator, Any + +from ..proxy import Proxy +from ..common.utils import get_available_port, new_socket_connection +from ..plugin import CacheResponsesPlugin + + +class TestCase(unittest.TestCase): + """Base TestCase class that automatically setup and teardown proxy.py.""" + + DEFAULT_PROXY_PY_STARTUP_FLAGS = [ + '--num-workers', '1', + '--threadless', + ] + + PROXY_PORT: int = 8899 + PROXY: Optional[Proxy] = None + INPUT_ARGS: Optional[List[str]] = None + + @classmethod + def setUpClass(cls) -> None: + cls.PROXY_PORT = get_available_port() + + cls.INPUT_ARGS = getattr(cls, 'PROXY_PY_STARTUP_FLAGS') \ + if hasattr(cls, 'PROXY_PY_STARTUP_FLAGS') \ + else cls.DEFAULT_PROXY_PY_STARTUP_FLAGS + cls.INPUT_ARGS.append('--port') + cls.INPUT_ARGS.append(str(cls.PROXY_PORT)) + + cls.PROXY = Proxy(input_args=cls.INPUT_ARGS) + cls.PROXY.flags.plugins[b'HttpProxyBasePlugin'].append(CacheResponsesPlugin) + + cls.PROXY.__enter__() + cls.wait_for_server(cls.PROXY_PORT) + + @staticmethod + def wait_for_server(proxy_port: int) -> None: + """Wait for proxy.py server to come up.""" + while True: + try: + conn = new_socket_connection( + ('localhost', proxy_port)) + conn.close() + break + except ConnectionRefusedError: + time.sleep(0.1) + + @classmethod + def tearDownClass(cls) -> None: + assert cls.PROXY + cls.PROXY.__exit__(None, None, None) + cls.PROXY_PORT = 8899 + cls.PROXY = None + cls.INPUT_ARGS = None + + @contextlib.contextmanager + def vcr(self) -> Generator[None, None, None]: + try: + CacheResponsesPlugin.ENABLED.set() + yield + finally: + CacheResponsesPlugin.ENABLED.clear() + + def run(self, result: Optional[unittest.TestResult] = None) -> Any: + super().run(result) diff --git a/setup.py b/setup.py index f1cba442ba..4ccfcee70c 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ 'proxy.core', 'proxy.dashboard', 'proxy.http', - 'proxy.plugin' + 'proxy.plugin', + 'proxy.testing', ], install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ diff --git a/tests/embed/__init__.py b/tests/embed/__init__.py new file mode 100644 index 0000000000..ba034136b9 --- /dev/null +++ b/tests/embed/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/embed/test_embed.py b/tests/embed/test_embed.py new file mode 100644 index 0000000000..9df6fcea2a --- /dev/null +++ b/tests/embed/test_embed.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import json +import http.client +import urllib.request + +from proxy import TestCase +from proxy.common.constants import DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE +from proxy.common.utils import socket_connection, build_http_request, build_http_response +from proxy.http.codes import httpStatusCodes +from proxy.http.methods import httpMethods + + +class TestProxyPyEmbedded(TestCase): + """This test case is a demonstration of proxy.TestCase and also serves as + integration test suite for proxy.py.""" + + PROXY_PY_STARTUP_FLAGS = TestCase.DEFAULT_PROXY_PY_STARTUP_FLAGS + [ + '--enable-web-server', + ] + + def test_with_proxy(self) -> None: + """Makes a HTTP request to in-build web server via proxy server.""" + with socket_connection(('localhost', self.PROXY_PORT)) as conn: + conn.send( + build_http_request( + httpMethods.GET, b'http://localhost:%d/' % self.PROXY_PORT, + headers={ + b'Host': b'localhost:%d' % self.PROXY_PORT, + }) + ) + response = conn.recv(DEFAULT_CLIENT_RECVBUF_SIZE) + self.assertEqual( + response, + build_http_response( + httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', + headers={ + b'Server': PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close' + } + ) + ) + + def test_proxy_vcr(self) -> None: + """With VCR enabled, proxy.py will cache responses for all HTTP(s) + requests made during the test. When test is re-run, until explicitly + disabled, proxy.py will replay responses from cache avoiding calls to + upstream servers. + + This feature only works iff proxy.py is used as a proxy server + for all HTTP(s) requests made during the test. + + Below we make a HTTP GET request using Python's urllib library.""" + with self.vcr(): + self.make_http_request_using_proxy() + + def test_proxy_no_vcr(self) -> None: + self.make_http_request_using_proxy() + + def make_http_request_using_proxy(self) -> None: + proxy_handler = urllib.request.ProxyHandler({ + 'http': 'http://localhost:%d' % self.PROXY_PORT, + }) + opener = urllib.request.build_opener(proxy_handler) + r: http.client.HTTPResponse = opener.open('http://httpbin.org/get') + self.assertEqual(r.status, 200) + data = json.loads(r.read(DEFAULT_CLIENT_RECVBUF_SIZE)) + self.assertEqual(data['args'], {}) + self.assertEqual(data['headers']['Host'], 'httpbin.org') diff --git a/tests/test_embed.py b/tests/test_embed.py deleted file mode 100644 index 585153e374..0000000000 --- a/tests/test_embed.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -from proxy.proxy import TestCase -from proxy.common.constants import DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE -from proxy.common.utils import socket_connection, build_http_request, build_http_response -from proxy.http.codes import httpStatusCodes -from proxy.http.methods import httpMethods - - -class TestProxyPyEmbedded(TestCase): - - PROXY_PY_STARTUP_FLAGS = [ - '--num-workers', '1', - '--enable-web-server', - ] - - def test_with_proxy(self) -> None: - """Makes a HTTP request to in-build web server via proxy server""" - with socket_connection(('localhost', self.proxy_port)) as conn: - conn.send( - build_http_request( - httpMethods.GET, b'http://localhost:%d/' % self.proxy_port, - headers={ - b'Host': b'localhost:%d' % self.proxy_port, - }) - ) - response = conn.recv(DEFAULT_CLIENT_RECVBUF_SIZE) - self.assertEqual( - response, - build_http_response( - httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', - headers={ - b'Server': PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close' - } - ) - ) From e03cae30d23074706e6655e6b66e5caa5a1c7fcc Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 22 Nov 2019 15:18:01 -0800 Subject: [PATCH 045/107] Initialize Menubar (#188) * Initialize MacOS Menubar application * Dashboard plugin at-least needs a shutdown hook to teardown any thread/processes started by dashboard backend plugin * Add menu bar icon * Add respective test directories * Sync test banners * Move plugin tests under its own package * Enable daemon for threads, other this wont shutdown cleanly --- menubar/.gitignore | 1 + menubar/proxy.py.xcodeproj/project.pbxproj | 602 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../UserInterfaceState.xcuserstate | Bin 0 -> 29254 bytes .../xcschemes/xcschememanagement.plist | 14 + menubar/proxy.py/AppDelegate.swift | 43 ++ .../AppIcon.appiconset/Contents.json | 58 ++ .../proxy.py/Assets.xcassets/Contents.json | 6 + .../Contents.json | 24 + .../StatusBarButtonImage@2x.png | Bin 0 -> 446 bytes menubar/proxy.py/Base.lproj/Main.storyboard | 70 ++ menubar/proxy.py/ContentView.swift | 23 + menubar/proxy.py/Info.plist | 38 ++ .../Preview Assets.xcassets/Contents.json | 6 + menubar/proxy.py/proxy_py.entitlements | 10 + menubar/proxy.pyTests/Info.plist | 22 + menubar/proxy.pyTests/proxy_pyTests.swift | 34 + menubar/proxy.pyUITests/Info.plist | 22 + menubar/proxy.pyUITests/proxy_pyUITests.swift | 43 ++ proxy/core/acceptor.py | 1 + proxy/core/event.py | 6 + proxy/dashboard/dashboard.py | 2 +- proxy/dashboard/plugin.py | 8 + tests/__init__.py | 4 +- tests/{embed => benchmark}/__init__.py | 3 +- tests/common/__init__.py | 3 +- tests/common/test_pki.py | 3 +- tests/common/test_text_bytes.py | 3 +- tests/common/test_utils.py | 3 +- tests/core/__init__.py | 3 +- tests/core/test_acceptor.py | 3 +- tests/core/test_acceptor_pool.py | 10 + tests/core/test_connection.py | 3 +- tests/dashboard/__init__.py | 10 + tests/http/__init__.py | 3 +- tests/http/test_chunk_parser.py | 3 +- tests/http/test_http_parser.py | 3 +- tests/http/test_http_proxy.py | 3 +- .../http/test_http_proxy_tls_interception.py | 3 +- tests/http/test_http_request_rejected.py | 3 +- tests/http/test_protocol_handler.py | 3 +- tests/http/test_web_server.py | 3 +- tests/http/test_websocket_client.py | 3 +- tests/http/test_websocket_frame.py | 3 +- tests/plugin/__init__.py | 10 + .../test_http_proxy_plugins.py} | 3 +- ...tp_proxy_plugins_with_tls_interception.py} | 3 +- tests/{http => plugin}/utils.py | 10 + tests/test_main.py | 3 +- tests/test_set_open_file_limit.py | 3 +- tests/testing/__init__.py | 10 + tests/{embed => testing}/test_embed.py | 3 +- 53 files changed, 1145 insertions(+), 26 deletions(-) create mode 100644 menubar/.gitignore create mode 100644 menubar/proxy.py.xcodeproj/project.pbxproj create mode 100644 menubar/proxy.py.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 menubar/proxy.py.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 menubar/proxy.py.xcodeproj/project.xcworkspace/xcuserdata/abhinav.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 menubar/proxy.py.xcodeproj/xcuserdata/abhinav.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 menubar/proxy.py/AppDelegate.swift create mode 100644 menubar/proxy.py/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 menubar/proxy.py/Assets.xcassets/Contents.json create mode 100644 menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/Contents.json create mode 100644 menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/StatusBarButtonImage@2x.png create mode 100644 menubar/proxy.py/Base.lproj/Main.storyboard create mode 100644 menubar/proxy.py/ContentView.swift create mode 100644 menubar/proxy.py/Info.plist create mode 100644 menubar/proxy.py/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 menubar/proxy.py/proxy_py.entitlements create mode 100644 menubar/proxy.pyTests/Info.plist create mode 100644 menubar/proxy.pyTests/proxy_pyTests.swift create mode 100644 menubar/proxy.pyUITests/Info.plist create mode 100644 menubar/proxy.pyUITests/proxy_pyUITests.swift rename tests/{embed => benchmark}/__init__.py (50%) create mode 100644 tests/dashboard/__init__.py create mode 100644 tests/plugin/__init__.py rename tests/{http/test_http_proxy_examples.py => plugin/test_http_proxy_plugins.py} (98%) rename tests/{http/test_http_proxy_examples_with_tls_interception.py => plugin/test_http_proxy_plugins_with_tls_interception.py} (97%) rename tests/{http => plugin}/utils.py (73%) create mode 100644 tests/testing/__init__.py rename tests/{embed => testing}/test_embed.py (94%) diff --git a/menubar/.gitignore b/menubar/.gitignore new file mode 100644 index 0000000000..e43b0f9889 --- /dev/null +++ b/menubar/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/menubar/proxy.py.xcodeproj/project.pbxproj b/menubar/proxy.py.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..90e415dca2 --- /dev/null +++ b/menubar/proxy.py.xcodeproj/project.pbxproj @@ -0,0 +1,602 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + AD1F92A7238864240088A917 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1F92A6238864240088A917 /* AppDelegate.swift */; }; + AD1F92A9238864240088A917 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1F92A8238864240088A917 /* ContentView.swift */; }; + AD1F92AB238864260088A917 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AD1F92AA238864260088A917 /* Assets.xcassets */; }; + AD1F92AE238864260088A917 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AD1F92AD238864260088A917 /* Preview Assets.xcassets */; }; + AD1F92B1238864260088A917 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AD1F92AF238864260088A917 /* Main.storyboard */; }; + AD1F92BD238864260088A917 /* proxy_pyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1F92BC238864260088A917 /* proxy_pyTests.swift */; }; + AD1F92C8238864260088A917 /* proxy_pyUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1F92C7238864260088A917 /* proxy_pyUITests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + AD1F92B9238864260088A917 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AD1F929B238864240088A917 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AD1F92A2238864240088A917; + remoteInfo = proxy.py; + }; + AD1F92C4238864260088A917 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AD1F929B238864240088A917 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AD1F92A2238864240088A917; + remoteInfo = proxy.py; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + AD1F92A3238864240088A917 /* proxy.py.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = proxy.py.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AD1F92A6238864240088A917 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AD1F92A8238864240088A917 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + AD1F92AA238864260088A917 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AD1F92AD238864260088A917 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + AD1F92B0238864260088A917 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + AD1F92B2238864260088A917 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AD1F92B3238864260088A917 /* proxy_py.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = proxy_py.entitlements; sourceTree = ""; }; + AD1F92B8238864260088A917 /* proxy.pyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = proxy.pyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AD1F92BC238864260088A917 /* proxy_pyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = proxy_pyTests.swift; sourceTree = ""; }; + AD1F92BE238864260088A917 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AD1F92C3238864260088A917 /* proxy.pyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = proxy.pyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AD1F92C7238864260088A917 /* proxy_pyUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = proxy_pyUITests.swift; sourceTree = ""; }; + AD1F92C9238864260088A917 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AD1F92A0238864240088A917 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD1F92B5238864260088A917 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD1F92C0238864260088A917 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AD1F929A238864240088A917 = { + isa = PBXGroup; + children = ( + AD1F92A5238864240088A917 /* proxy.py */, + AD1F92BB238864260088A917 /* proxy.pyTests */, + AD1F92C6238864260088A917 /* proxy.pyUITests */, + AD1F92A4238864240088A917 /* Products */, + ); + sourceTree = ""; + }; + AD1F92A4238864240088A917 /* Products */ = { + isa = PBXGroup; + children = ( + AD1F92A3238864240088A917 /* proxy.py.app */, + AD1F92B8238864260088A917 /* proxy.pyTests.xctest */, + AD1F92C3238864260088A917 /* proxy.pyUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + AD1F92A5238864240088A917 /* proxy.py */ = { + isa = PBXGroup; + children = ( + AD1F92A6238864240088A917 /* AppDelegate.swift */, + AD1F92A8238864240088A917 /* ContentView.swift */, + AD1F92AA238864260088A917 /* Assets.xcassets */, + AD1F92AF238864260088A917 /* Main.storyboard */, + AD1F92B2238864260088A917 /* Info.plist */, + AD1F92B3238864260088A917 /* proxy_py.entitlements */, + AD1F92AC238864260088A917 /* Preview Content */, + ); + path = proxy.py; + sourceTree = ""; + }; + AD1F92AC238864260088A917 /* Preview Content */ = { + isa = PBXGroup; + children = ( + AD1F92AD238864260088A917 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + AD1F92BB238864260088A917 /* proxy.pyTests */ = { + isa = PBXGroup; + children = ( + AD1F92BC238864260088A917 /* proxy_pyTests.swift */, + AD1F92BE238864260088A917 /* Info.plist */, + ); + path = proxy.pyTests; + sourceTree = ""; + }; + AD1F92C6238864260088A917 /* proxy.pyUITests */ = { + isa = PBXGroup; + children = ( + AD1F92C7238864260088A917 /* proxy_pyUITests.swift */, + AD1F92C9238864260088A917 /* Info.plist */, + ); + path = proxy.pyUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AD1F92A2238864240088A917 /* proxy.py */ = { + isa = PBXNativeTarget; + buildConfigurationList = AD1F92CC238864260088A917 /* Build configuration list for PBXNativeTarget "proxy.py" */; + buildPhases = ( + AD1F929F238864240088A917 /* Sources */, + AD1F92A0238864240088A917 /* Frameworks */, + AD1F92A1238864240088A917 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = proxy.py; + productName = proxy.py; + productReference = AD1F92A3238864240088A917 /* proxy.py.app */; + productType = "com.apple.product-type.application"; + }; + AD1F92B7238864260088A917 /* proxy.pyTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AD1F92CF238864260088A917 /* Build configuration list for PBXNativeTarget "proxy.pyTests" */; + buildPhases = ( + AD1F92B4238864260088A917 /* Sources */, + AD1F92B5238864260088A917 /* Frameworks */, + AD1F92B6238864260088A917 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AD1F92BA238864260088A917 /* PBXTargetDependency */, + ); + name = proxy.pyTests; + productName = proxy.pyTests; + productReference = AD1F92B8238864260088A917 /* proxy.pyTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + AD1F92C2238864260088A917 /* proxy.pyUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AD1F92D2238864260088A917 /* Build configuration list for PBXNativeTarget "proxy.pyUITests" */; + buildPhases = ( + AD1F92BF238864260088A917 /* Sources */, + AD1F92C0238864260088A917 /* Frameworks */, + AD1F92C1238864260088A917 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AD1F92C5238864260088A917 /* PBXTargetDependency */, + ); + name = proxy.pyUITests; + productName = proxy.pyUITests; + productReference = AD1F92C3238864260088A917 /* proxy.pyUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AD1F929B238864240088A917 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1120; + LastUpgradeCheck = 1120; + ORGANIZATIONNAME = "Abhinav Singh"; + TargetAttributes = { + AD1F92A2238864240088A917 = { + CreatedOnToolsVersion = 11.2.1; + }; + AD1F92B7238864260088A917 = { + CreatedOnToolsVersion = 11.2.1; + TestTargetID = AD1F92A2238864240088A917; + }; + AD1F92C2238864260088A917 = { + CreatedOnToolsVersion = 11.2.1; + TestTargetID = AD1F92A2238864240088A917; + }; + }; + }; + buildConfigurationList = AD1F929E238864240088A917 /* Build configuration list for PBXProject "proxy.py" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AD1F929A238864240088A917; + productRefGroup = AD1F92A4238864240088A917 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AD1F92A2238864240088A917 /* proxy.py */, + AD1F92B7238864260088A917 /* proxy.pyTests */, + AD1F92C2238864260088A917 /* proxy.pyUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AD1F92A1238864240088A917 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD1F92B1238864260088A917 /* Main.storyboard in Resources */, + AD1F92AE238864260088A917 /* Preview Assets.xcassets in Resources */, + AD1F92AB238864260088A917 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD1F92B6238864260088A917 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD1F92C1238864260088A917 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AD1F929F238864240088A917 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD1F92A9238864240088A917 /* ContentView.swift in Sources */, + AD1F92A7238864240088A917 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD1F92B4238864260088A917 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD1F92BD238864260088A917 /* proxy_pyTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AD1F92BF238864260088A917 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AD1F92C8238864260088A917 /* proxy_pyUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AD1F92BA238864260088A917 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AD1F92A2238864240088A917 /* proxy.py */; + targetProxy = AD1F92B9238864260088A917 /* PBXContainerItemProxy */; + }; + AD1F92C5238864260088A917 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AD1F92A2238864240088A917 /* proxy.py */; + targetProxy = AD1F92C4238864260088A917 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + AD1F92AF238864260088A917 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AD1F92B0238864260088A917 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + AD1F92CA238864260088A917 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AD1F92CB238864260088A917 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + AD1F92CD238864260088A917 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = proxy.py/proxy_py.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_ASSET_PATHS = "\"proxy.py/Preview Content\""; + DEVELOPMENT_TEAM = G3B58EP2ZT; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = proxy.py/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.15; + PRODUCT_BUNDLE_IDENTIFIER = "com.abhinavsingh.proxy-py"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AD1F92CE238864260088A917 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = proxy.py/proxy_py.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_ASSET_PATHS = "\"proxy.py/Preview Content\""; + DEVELOPMENT_TEAM = G3B58EP2ZT; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = proxy.py/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.15; + PRODUCT_BUNDLE_IDENTIFIER = "com.abhinavsingh.proxy-py"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + AD1F92D0238864260088A917 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G3B58EP2ZT; + INFOPLIST_FILE = proxy.pyTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.15; + PRODUCT_BUNDLE_IDENTIFIER = "com.abhinavsingh.proxy-pyTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/proxy.py.app/Contents/MacOS/proxy.py"; + }; + name = Debug; + }; + AD1F92D1238864260088A917 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G3B58EP2ZT; + INFOPLIST_FILE = proxy.pyTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.15; + PRODUCT_BUNDLE_IDENTIFIER = "com.abhinavsingh.proxy-pyTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/proxy.py.app/Contents/MacOS/proxy.py"; + }; + name = Release; + }; + AD1F92D3238864260088A917 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G3B58EP2ZT; + INFOPLIST_FILE = proxy.pyUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.abhinavsingh.proxy-pyUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = proxy.py; + }; + name = Debug; + }; + AD1F92D4238864260088A917 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G3B58EP2ZT; + INFOPLIST_FILE = proxy.pyUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.abhinavsingh.proxy-pyUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = proxy.py; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AD1F929E238864240088A917 /* Build configuration list for PBXProject "proxy.py" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD1F92CA238864260088A917 /* Debug */, + AD1F92CB238864260088A917 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AD1F92CC238864260088A917 /* Build configuration list for PBXNativeTarget "proxy.py" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD1F92CD238864260088A917 /* Debug */, + AD1F92CE238864260088A917 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AD1F92CF238864260088A917 /* Build configuration list for PBXNativeTarget "proxy.pyTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD1F92D0238864260088A917 /* Debug */, + AD1F92D1238864260088A917 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AD1F92D2238864260088A917 /* Build configuration list for PBXNativeTarget "proxy.pyUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD1F92D3238864260088A917 /* Debug */, + AD1F92D4238864260088A917 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AD1F929B238864240088A917 /* Project object */; +} diff --git a/menubar/proxy.py.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/menubar/proxy.py.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..9243bd42bc --- /dev/null +++ b/menubar/proxy.py.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/menubar/proxy.py.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/menubar/proxy.py.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/menubar/proxy.py.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/menubar/proxy.py.xcodeproj/project.xcworkspace/xcuserdata/abhinav.xcuserdatad/UserInterfaceState.xcuserstate b/menubar/proxy.py.xcodeproj/project.xcworkspace/xcuserdata/abhinav.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..9f1757a0c3de32149b20a2e78fd79493a278b1f9 GIT binary patch literal 29254 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 + + + + SchemeUserState + + proxy.py.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/menubar/proxy.py/AppDelegate.swift b/menubar/proxy.py/AppDelegate.swift new file mode 100644 index 0000000000..f4ac49883d --- /dev/null +++ b/menubar/proxy.py/AppDelegate.swift @@ -0,0 +1,43 @@ +// +// AppDelegate.swift +// proxy.py +// +// Created by Abhinav Singh on 11/22/19. +// Copyright © 2019 Abhinav Singh. All rights reserved. +// + +import Cocoa +import SwiftUI + +@NSApplicationMain +class AppDelegate: NSObject, NSApplicationDelegate { + + var window: NSWindow! + + let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength) + + func applicationDidFinishLaunching(_ aNotification: Notification) { + if let button = statusItem.button { + button.image = NSImage(named:NSImage.Name("StatusBarButtonImage")) + // button.action = #selector(helloWorld(_:)) + } + constructMenu() + } + + func applicationWillTerminate(_ aNotification: Notification) { + // Insert code here to tear down your application + } + + @objc func helloWorld(_ sender: Any?) { + print("Hello World") + } + + func constructMenu() { + let menu = NSMenu() + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "About proxy.py", action: #selector(AppDelegate.helloWorld(_:)), keyEquivalent: "A")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + statusItem.menu = menu + } +} diff --git a/menubar/proxy.py/Assets.xcassets/AppIcon.appiconset/Contents.json b/menubar/proxy.py/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..2db2b1c7c6 --- /dev/null +++ b/menubar/proxy.py/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "32x32", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "32x32", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "128x128", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "128x128", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "256x256", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "256x256", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "512x512", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "512x512", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/menubar/proxy.py/Assets.xcassets/Contents.json b/menubar/proxy.py/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..da4a164c91 --- /dev/null +++ b/menubar/proxy.py/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/Contents.json b/menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/Contents.json new file mode 100644 index 0000000000..69b780babb --- /dev/null +++ b/menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarButtonImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/StatusBarButtonImage@2x.png b/menubar/proxy.py/Assets.xcassets/StatusBarButtonImage.imageset/StatusBarButtonImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..91d14db61667a0709d5af2e5f10cccce4d0994e2 GIT binary patch literal 446 zcmV;v0YUzWP)i^6L~nNSgXQY;0lx0U)gm z6|nIo2ieb%E;PV-hyeJ1&-+mMlBUROWjpOdQL9>MA8CSoUQ5O~e75Z<6~~|1u|THU z`yNQUutTQk>c9|k+Sj5H!#X{qZGkPK9Kx=M@*3)h{vj#?$TgmQ$)gXWyrY=UIDQ;T zZZjkW?uMiw(2$sxEXUuH<#<@K3^_Pso6;{FO{uFbFAV{s+H%*}+mDHBB*;|T5%6nv o7${ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/menubar/proxy.py/ContentView.swift b/menubar/proxy.py/ContentView.swift new file mode 100644 index 0000000000..52fee63601 --- /dev/null +++ b/menubar/proxy.py/ContentView.swift @@ -0,0 +1,23 @@ +// +// ContentView.swift +// proxy.py +// +// Created by Abhinav Singh on 11/22/19. +// Copyright © 2019 Abhinav Singh. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + Text("Hello, World!") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/menubar/proxy.py/Info.plist b/menubar/proxy.py/Info.plist new file mode 100644 index 0000000000..5acca6d2c2 --- /dev/null +++ b/menubar/proxy.py/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2019 Abhinav Singh. All rights reserved. + NSMainStoryboardFile + Main + NSPrincipalClass + NSApplication + NSSupportsAutomaticTermination + + LSUIElement + + NSSupportsSuddenTermination + + + diff --git a/menubar/proxy.py/Preview Content/Preview Assets.xcassets/Contents.json b/menubar/proxy.py/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000000..da4a164c91 --- /dev/null +++ b/menubar/proxy.py/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/menubar/proxy.py/proxy_py.entitlements b/menubar/proxy.py/proxy_py.entitlements new file mode 100644 index 0000000000..f2ef3ae026 --- /dev/null +++ b/menubar/proxy.py/proxy_py.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/menubar/proxy.pyTests/Info.plist b/menubar/proxy.pyTests/Info.plist new file mode 100644 index 0000000000..64d65ca495 --- /dev/null +++ b/menubar/proxy.pyTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/menubar/proxy.pyTests/proxy_pyTests.swift b/menubar/proxy.pyTests/proxy_pyTests.swift new file mode 100644 index 0000000000..6e36b94bd0 --- /dev/null +++ b/menubar/proxy.pyTests/proxy_pyTests.swift @@ -0,0 +1,34 @@ +// +// proxy_pyTests.swift +// proxy.pyTests +// +// Created by Abhinav Singh on 11/22/19. +// Copyright © 2019 Abhinav Singh. All rights reserved. +// + +import XCTest +@testable import proxy_py + +class proxy_pyTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/menubar/proxy.pyUITests/Info.plist b/menubar/proxy.pyUITests/Info.plist new file mode 100644 index 0000000000..64d65ca495 --- /dev/null +++ b/menubar/proxy.pyUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/menubar/proxy.pyUITests/proxy_pyUITests.swift b/menubar/proxy.pyUITests/proxy_pyUITests.swift new file mode 100644 index 0000000000..d9b4ea9775 --- /dev/null +++ b/menubar/proxy.pyUITests/proxy_pyUITests.swift @@ -0,0 +1,43 @@ +// +// proxy_pyUITests.swift +// proxy.pyUITests +// +// Created by Abhinav Singh on 11/22/19. +// Copyright © 2019 Abhinav Singh. All rights reserved. +// + +import XCTest + +class proxy_pyUITests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use recording to get started writing UI tests. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { + XCUIApplication().launch() + } + } + } +} diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor.py index 0c7afce410..2c376dbbce 100644 --- a/proxy/core/acceptor.py +++ b/proxy/core/acceptor.py @@ -194,6 +194,7 @@ def start_work(self, conn: socket.socket, addr: Tuple[str, int]) -> None: event_queue=self.event_queue ) work_thread = threading.Thread(target=work.run) + work_thread.daemon = True work.publish_event( event_name=eventNames.WORK_STARTED, event_payload={'fileno': conn.fileno(), 'addr': addr}, diff --git a/proxy/core/event.py b/proxy/core/event.py index a689247fec..a9fe995596 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -184,8 +184,13 @@ def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: self.relay_thread.start() self.relay_sub_id = uuid.uuid4().hex self.event_queue.subscribe(self.relay_sub_id, self.relay_channel) + logger.debug('Subscribed relay sub id %s from core events', self.relay_sub_id) def unsubscribe(self) -> None: + if self.relay_sub_id is None: + logger.warning('Unsubscribe called without existing subscription') + return + assert self.relay_thread assert self.relay_shutdown assert self.relay_channel @@ -194,6 +199,7 @@ def unsubscribe(self) -> None: self.event_queue.unsubscribe(self.relay_sub_id) self.relay_shutdown.set() self.relay_thread.join() + logger.debug('Un-subscribed relay sub id %s from core events', self.relay_sub_id) self.relay_thread = None self.relay_shutdown = None diff --git a/proxy/dashboard/dashboard.py b/proxy/dashboard/dashboard.py index 23d58a3786..b053414afc 100644 --- a/proxy/dashboard/dashboard.py +++ b/proxy/dashboard/dashboard.py @@ -100,7 +100,7 @@ def on_websocket_message(self, frame: WebsocketFrame) -> None: def on_websocket_close(self) -> None: logger.info('app ws closed') - # unsubscribe + # TODO(abhinavsingh): unsubscribe def reply(self, data: Dict[str, Any]) -> None: self.client.queue( diff --git a/proxy/dashboard/plugin.py b/proxy/dashboard/plugin.py index bbaf8b1164..0574bca97c 100644 --- a/proxy/dashboard/plugin.py +++ b/proxy/dashboard/plugin.py @@ -36,11 +36,19 @@ def methods(self) -> List[str]: """Return list of methods that this plugin will handle.""" pass + def connected(self) -> None: + """Invoked when client websocket handshake finishes.""" + pass + @abstractmethod def handle_message(self, message: Dict[str, Any]) -> None: """Handle messages for registered methods.""" pass + def disconnected(self) -> None: + """Invoked when client websocket connection gets closed.""" + pass + def reply(self, data: Dict[str, Any]) -> None: self.client.queue( WebsocketFrame.text( diff --git a/tests/__init__.py b/tests/__init__.py index 81b1532d33..891fe5fddd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,8 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable - proxy server for Application debugging, testing and development. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/embed/__init__.py b/tests/benchmark/__init__.py similarity index 50% rename from tests/embed/__init__.py rename to tests/benchmark/__init__.py index ba034136b9..232621f0b5 100644 --- a/tests/embed/__init__.py +++ b/tests/benchmark/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/common/__init__.py b/tests/common/__init__.py index ba034136b9..232621f0b5 100644 --- a/tests/common/__init__.py +++ b/tests/common/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/common/test_pki.py b/tests/common/test_pki.py index 7a2b7d33f5..a896de1ecb 100644 --- a/tests/common/test_pki.py +++ b/tests/common/test_pki.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/common/test_text_bytes.py b/tests/common/test_text_bytes.py index 43a23559d7..c80fa23b5e 100644 --- a/tests/common/test_text_bytes.py +++ b/tests/common/test_text_bytes.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/common/test_utils.py b/tests/common/test_utils.py index 2de0574f3a..5db1d508b4 100644 --- a/tests/common/test_utils.py +++ b/tests/common/test_utils.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/core/__init__.py b/tests/core/__init__.py index ba034136b9..232621f0b5 100644 --- a/tests/core/__init__.py +++ b/tests/core/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/core/test_acceptor.py b/tests/core/test_acceptor.py index db3a3a3b6f..92ae2fb66e 100644 --- a/tests/core/test_acceptor.py +++ b/tests/core/test_acceptor.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/core/test_acceptor_pool.py b/tests/core/test_acceptor_pool.py index 9c9ceba1af..51f10d6095 100644 --- a/tests/core/test_acceptor_pool.py +++ b/tests/core/test_acceptor_pool.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" import unittest import socket from unittest import mock diff --git a/tests/core/test_connection.py b/tests/core/test_connection.py index 2389f6e75c..6808a465da 100644 --- a/tests/core/test_connection.py +++ b/tests/core/test_connection.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000000..232621f0b5 --- /dev/null +++ b/tests/dashboard/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/http/__init__.py b/tests/http/__init__.py index ba034136b9..232621f0b5 100644 --- a/tests/http/__init__.py +++ b/tests/http/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_chunk_parser.py b/tests/http/test_chunk_parser.py index da4018af03..94b71afb6d 100644 --- a/tests/http/test_chunk_parser.py +++ b/tests/http/test_chunk_parser.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index 6f20250edc..d99e832822 100644 --- a/tests/http/test_http_parser.py +++ b/tests/http/test_http_parser.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_http_proxy.py b/tests/http/test_http_proxy.py index 8d84c310db..e3d861af7d 100644 --- a/tests/http/test_http_proxy.py +++ b/tests/http/test_http_proxy.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_http_proxy_tls_interception.py b/tests/http/test_http_proxy_tls_interception.py index 228b2ea082..68dc06f0e4 100644 --- a/tests/http/test_http_proxy_tls_interception.py +++ b/tests/http/test_http_proxy_tls_interception.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_http_request_rejected.py b/tests/http/test_http_request_rejected.py index 828bb93d55..59eac81c3b 100644 --- a/tests/http/test_http_request_rejected.py +++ b/tests/http/test_http_request_rejected.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_protocol_handler.py b/tests/http/test_protocol_handler.py index 6dd1187ca0..dac588400a 100644 --- a/tests/http/test_protocol_handler.py +++ b/tests/http/test_protocol_handler.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_web_server.py b/tests/http/test_web_server.py index ac7a0e47b4..6b2148c07f 100644 --- a/tests/http/test_web_server.py +++ b/tests/http/test_web_server.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_websocket_client.py b/tests/http/test_websocket_client.py index 3c79307166..060fba1068 100644 --- a/tests/http/test_websocket_client.py +++ b/tests/http/test_websocket_client.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_websocket_frame.py b/tests/http/test_websocket_frame.py index 0dfc77d13c..c18919898b 100644 --- a/tests/http/test_websocket_frame.py +++ b/tests/http/test_websocket_frame.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 0000000000..232621f0b5 --- /dev/null +++ b/tests/plugin/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/http/test_http_proxy_examples.py b/tests/plugin/test_http_proxy_plugins.py similarity index 98% rename from tests/http/test_http_proxy_examples.py rename to tests/plugin/test_http_proxy_plugins.py index 994e9059a0..b347a880b8 100644 --- a/tests/http/test_http_proxy_examples.py +++ b/tests/plugin/test_http_proxy_plugins.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/test_http_proxy_examples_with_tls_interception.py b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py similarity index 97% rename from tests/http/test_http_proxy_examples_with_tls_interception.py rename to tests/plugin/test_http_proxy_plugins_with_tls_interception.py index efe781c8f2..5f2def28d1 100644 --- a/tests/http/test_http_proxy_examples_with_tls_interception.py +++ b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/http/utils.py b/tests/plugin/utils.py similarity index 73% rename from tests/http/utils.py rename to tests/plugin/utils.py index 3d0e3f953a..093436796a 100644 --- a/tests/http/utils.py +++ b/tests/plugin/utils.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" from typing import Type from proxy.http.proxy import HttpProxyBasePlugin diff --git a/tests/test_main.py b/tests/test_main.py index d4550267cd..c927a2a120 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/test_set_open_file_limit.py b/tests/test_set_open_file_limit.py index a2be8e6613..7eddcf0ffe 100644 --- a/tests/test_set_open_file_limit.py +++ b/tests/test_set_open_file_limit.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py new file mode 100644 index 0000000000..232621f0b5 --- /dev/null +++ b/tests/testing/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/tests/embed/test_embed.py b/tests/testing/test_embed.py similarity index 94% rename from tests/embed/test_embed.py rename to tests/testing/test_embed.py index 9df6fcea2a..43dffbd544 100644 --- a/tests/embed/test_embed.py +++ b/tests/testing/test_embed.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. From 81e2c89afbb47e7938efe0692c4ad8b056b625b9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 24 Nov 2019 13:55:41 +0200 Subject: [PATCH 046/107] Update twine from 3.0.0 to 3.1.0 (#190) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index fdcfec7ab1..f1591af87c 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ -twine==3.0.0 +twine==3.1.0 wheel==0.33.6 setuptools==41.6.0 From 4fa4b9947619ee8011d040c12b7f82f524f79fa3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 24 Nov 2019 19:38:15 +0200 Subject: [PATCH 047/107] Update setuptools from 41.6.0 to 42.0.0 (#191) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index f1591af87c..ff2691ec14 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.0 wheel==0.33.6 -setuptools==41.6.0 +setuptools==42.0.0 From 97670104252e7c0d881d0f7bb1d569152ee3bd25 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 24 Nov 2019 11:59:41 -0800 Subject: [PATCH 048/107] Memory optimizations (#189) * Avoid persisting raw content in memory within parser, simply parse and throw-away. Addresses #187 * Clarity in test comments --- proxy/http/parser.py | 25 +++++++++++++------------ tests/http/test_protocol_handler.py | 10 +++------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/proxy/http/parser.py b/proxy/http/parser.py index 09a602901e..24b410cb40 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -45,8 +45,7 @@ def __init__(self, parser_type: int) -> None: self.type: int = parser_type self.state: int = httpParserStates.INITIALIZED - # Raw bytes as passed to parse(raw) method and its total size - self.bytes: bytes = b'' + # Total size of raw bytes passed for parsing self.total_size: int = 0 # Buffer to hold unprocessed bytes @@ -118,7 +117,7 @@ def set_line_attributes(self) -> None: self.host, self.port = self.url.hostname, self.url.port \ if self.url.port else 80 else: - raise KeyError(b'Invalid request\n%b' % self.bytes) + raise KeyError('Invalid request. Method: %r, Url: %r' % (self.method, self.url)) self.path = self.build_url() def is_chunked_encoded(self) -> bool: @@ -129,13 +128,11 @@ def parse(self, raw: bytes) -> None: """Parses Http request out of raw bytes. Check HttpParser state after parse has successfully returned.""" - self.bytes += raw self.total_size += len(raw) - - # Prepend past buffer raw = self.buffer + raw self.buffer = b'' + # TODO(abhinavsingh): Someday clean this up. more = True if len(raw) > 0 else False while more and self.state != httpParserStates.COMPLETE: if self.state in ( @@ -182,21 +179,25 @@ def process(self, raw: bytes) -> Tuple[bool, bytes]: else: self.process_header(line) - # When connect request is received without a following host header - # See - # `TestHttpParser.test_connect_request_without_host_header_request_parse` - # for details + # TODO(abhinavsingh): Can these be generalized instead of per-case handling? + # When server sends a response line without any header or body e.g. + # HTTP/1.1 200 Connection established\r\n\r\n if self.state == httpParserStates.LINE_RCVD and \ self.type == httpParserTypes.RESPONSE_PARSER and \ raw == CRLF: self.state = httpParserStates.COMPLETE + # When connect request is received without a following host header + # See + # `TestHttpParser.test_connect_request_without_host_header_request_parse` + # for details + # # When raw request has ended with \r\n\r\n and no more http headers are expected # See `TestHttpParser.test_request_parse_without_content_length` and # `TestHttpParser.test_response_parse_without_content_length` for details elif self.state == httpParserStates.HEADERS_COMPLETE and \ self.type == httpParserTypes.REQUEST_PARSER and \ self.method != httpMethods.POST and \ - self.bytes.endswith(CRLF * 2): + raw == b'': self.state = httpParserStates.COMPLETE elif self.state == httpParserStates.HEADERS_COMPLETE and \ self.type == httpParserTypes.REQUEST_PARSER and \ @@ -205,7 +206,7 @@ def process(self, raw: bytes) -> Tuple[bool, bytes]: (b'content-length' not in self.headers or (b'content-length' in self.headers and int(self.headers[b'content-length'][1]) == 0)) and \ - self.bytes.endswith(CRLF * 2): + raw == b'': self.state = httpParserStates.COMPLETE return len(raw) > 0, raw diff --git a/tests/http/test_protocol_handler.py b/tests/http/test_protocol_handler.py index dac588400a..4113ee05fe 100644 --- a/tests/http/test_protocol_handler.py +++ b/tests/http/test_protocol_handler.py @@ -322,20 +322,16 @@ def assert_data_queued_to_server(self, server: mock.Mock) -> None: self._conn.send.call_args[0][0], HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) - self._conn.recv.return_value = CRLF.join([ + pkt = CRLF.join([ b'GET / HTTP/1.1', b'Host: localhost:%d' % self.http_server_port, b'User-Agent: proxy.py/%s' % bytes_(__version__), CRLF ]) + + self._conn.recv.return_value = pkt self.protocol_handler.run_once() - pkt = CRLF.join([ - b'GET / HTTP/1.1', - b'Host: localhost:%d' % self.http_server_port, - b'User-Agent: proxy.py/%s' % bytes_(__version__), - CRLF - ]) server.queue.assert_called_once_with(pkt) server.buffer_size.return_value = len(pkt) server.flush.assert_not_called() From f2b9a632bcf0165cfc1562ac5cbbc505d1fd0310 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 25 Nov 2019 21:22:01 +0200 Subject: [PATCH 049/107] Update setuptools from 42.0.0 to 42.0.1 (#193) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index ff2691ec14..9265a0039a 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.0 wheel==0.33.6 -setuptools==42.0.0 +setuptools==42.0.1 From 093e852df55f63a14f86c201db114e1ea6803cc3 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 26 Nov 2019 17:59:26 -0800 Subject: [PATCH 050/107] Make connection queue / recv work with memoryview to avoid copies (#192) * connection.recv now returns a memoryview * Make connection.queue also memoryview compliant * autopep8 * wrap in memoryview as necessary * Add default timeout for socket_connection and test_embed urllib * Fix tests * Skip TestProxyPyEmbedded for now, verifying GitHub actions * Add timeout for wait_for_server and skip only if GITHUB_ACTIONS env variable is set * Verify if GitHub Action fails due to wait_for_server spinning forever * Add test for wait_for_server timeout error exception * GitHub action hangs irrespective of wait_for_server timeout, disable TestEmbed for GitHub actions --- Makefile | 1 + proxy/common/utils.py | 9 +++-- proxy/core/connection.py | 30 +++++++------- proxy/core/event.py | 8 +++- proxy/dashboard/dashboard.py | 8 ++-- proxy/dashboard/inspect_traffic.py | 8 ++-- proxy/dashboard/plugin.py | 4 +- proxy/http/exception.py | 20 +++++----- proxy/http/handler.py | 18 +++++---- proxy/http/inspector.py | 6 +-- proxy/http/parser.py | 4 +- proxy/http/proxy.py | 40 ++++++++++++------- proxy/http/server.py | 36 +++++++++-------- proxy/http/websocket.py | 6 ++- proxy/plugin/cache/base.py | 2 +- proxy/plugin/cache/store/base.py | 2 +- proxy/plugin/cache/store/disk.py | 4 +- proxy/plugin/filter_by_upstream.py | 2 +- proxy/plugin/man_in_the_middle.py | 6 +-- proxy/plugin/mock_rest_api.py | 10 ++--- proxy/plugin/modify_post_data.py | 2 +- proxy/plugin/redirect_to_custom_server.py | 2 +- proxy/plugin/shortlink.py | 10 ++--- proxy/plugin/web_server_route.py | 8 ++-- proxy/testing/test_case.py | 10 ++++- tests/common/test_pki.py | 26 +++++++++--- tests/common/test_utils.py | 4 +- tests/http/test_protocol_handler.py | 8 ++-- tests/http/test_web_server.py | 2 +- tests/plugin/test_http_proxy_plugins.py | 6 +-- ...ttp_proxy_plugins_with_tls_interception.py | 4 +- tests/testing/test_embed.py | 18 ++++++--- tests/testing/test_test_case.py | 21 ++++++++++ 33 files changed, 212 insertions(+), 133 deletions(-) create mode 100644 tests/testing/test_test_case.py diff --git a/Makefile b/Makefile index 7173b55465..c3ae9c7cc7 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ autopep8: autopep8 --recursive --in-place --aggressive proxy/*.py autopep8 --recursive --in-place --aggressive proxy/*/*.py autopep8 --recursive --in-place --aggressive tests/*.py + autopep8 --recursive --in-place --aggressive tests/*/*.py autopep8 --recursive --in-place --aggressive setup.py https-certificates: diff --git a/proxy/common/utils.py b/proxy/common/utils.py index ae9a13d2fa..47c4c9a4f1 100644 --- a/proxy/common/utils.py +++ b/proxy/common/utils.py @@ -16,7 +16,7 @@ from types import TracebackType from typing import Optional, Dict, Any, List, Tuple, Type, Callable -from .constants import HTTP_1_1, COLON, WHITESPACE, CRLF +from .constants import HTTP_1_1, COLON, WHITESPACE, CRLF, DEFAULT_TIMEOUT def text_(s: Any, encoding: str = 'utf-8', errors: str = 'strict') -> Any: @@ -148,17 +148,20 @@ def find_http_line(raw: bytes) -> Tuple[Optional[bytes], bytes]: return line, rest -def new_socket_connection(addr: Tuple[str, int]) -> socket.socket: +def new_socket_connection( + addr: Tuple[str, int], timeout: int = DEFAULT_TIMEOUT) -> socket.socket: conn = None try: ip = ipaddress.ip_address(addr[0]) if ip.version == 4: conn = socket.socket( socket.AF_INET, socket.SOCK_STREAM, 0) + conn.settimeout(timeout) conn.connect(addr) else: conn = socket.socket( socket.AF_INET6, socket.SOCK_STREAM, 0) + conn.settimeout(timeout) conn.connect((addr[0], addr[1], 0, 0)) except ValueError: pass # does not appear to be an IPv4 or IPv6 address @@ -167,7 +170,7 @@ def new_socket_connection(addr: Tuple[str, int]) -> socket.socket: return conn # try to establish dual stack IPv4/IPv6 connection. - return socket.create_connection(addr) + return socket.create_connection(addr, timeout=timeout) class socket_connection(contextlib.ContextDecorator): diff --git a/proxy/core/connection.py b/proxy/core/connection.py index 8c19a601bd..7b5fe6c953 100644 --- a/proxy/core/connection.py +++ b/proxy/core/connection.py @@ -12,7 +12,7 @@ import ssl import logging from abc import ABC, abstractmethod -from typing import NamedTuple, Optional, Union, Tuple +from typing import NamedTuple, Optional, Union, Tuple, List from ..common.constants import DEFAULT_BUFFER_SIZE from ..common.utils import new_socket_connection @@ -41,7 +41,7 @@ class TcpConnection(ABC): a socket connection object.""" def __init__(self, tag: int): - self.buffer: bytes = b'' + self.buffer: List[memoryview] = [] self.closed: bool = False self.tag: str = 'server' if tag == tcpConnectionTypes.SERVER else 'client' @@ -55,7 +55,8 @@ def send(self, data: bytes) -> int: """Users must handle BrokenPipeError exceptions""" return self.connection.send(data) - def recv(self, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Optional[bytes]: + def recv( + self, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Optional[memoryview]: """Users must handle socket.error exceptions""" data: bytes = self.connection.recv(buffer_size) if len(data) == 0: @@ -64,7 +65,7 @@ def recv(self, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Optional[bytes]: 'received %d bytes from %s' % (len(data), self.tag)) # logger.info(data) - return data + return memoryview(data) def close(self) -> bool: if not self.closed: @@ -72,23 +73,22 @@ def close(self) -> bool: self.closed = True return self.closed - def buffer_size(self) -> int: - return len(self.buffer) - def has_buffer(self) -> bool: - return self.buffer_size() > 0 + return len(self.buffer) > 0 - def queue(self, data: bytes) -> int: - self.buffer += data - return len(data) + def queue(self, mv: memoryview) -> None: + self.buffer.append(mv) def flush(self) -> int: """Users must handle BrokenPipeError exceptions""" - if self.buffer_size() == 0: + if not self.has_buffer(): return 0 - sent: int = self.send(self.buffer) - # logger.info(self.buffer[:sent]) - self.buffer = self.buffer[sent:] + mv = self.buffer[0] + sent: int = self.send(mv.tobytes()) + if sent == len(mv): + self.buffer.pop(0) + else: + self.buffer[0] = memoryview(mv.tobytes()[sent:]) logger.debug('flushed %d bytes to %s' % (sent, self.tag)) return sent diff --git a/proxy/core/event.py b/proxy/core/event.py index a9fe995596..8bd3b0b5ef 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -184,7 +184,9 @@ def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: self.relay_thread.start() self.relay_sub_id = uuid.uuid4().hex self.event_queue.subscribe(self.relay_sub_id, self.relay_channel) - logger.debug('Subscribed relay sub id %s from core events', self.relay_sub_id) + logger.debug( + 'Subscribed relay sub id %s from core events', + self.relay_sub_id) def unsubscribe(self) -> None: if self.relay_sub_id is None: @@ -199,7 +201,9 @@ def unsubscribe(self) -> None: self.event_queue.unsubscribe(self.relay_sub_id) self.relay_shutdown.set() self.relay_thread.join() - logger.debug('Un-subscribed relay sub id %s from core events', self.relay_sub_id) + logger.debug( + 'Un-subscribed relay sub id %s from core events', + self.relay_sub_id) self.relay_thread = None self.relay_shutdown = None diff --git a/proxy/dashboard/dashboard.py b/proxy/dashboard/dashboard.py index b053414afc..845bd071b8 100644 --- a/proxy/dashboard/dashboard.py +++ b/proxy/dashboard/dashboard.py @@ -67,14 +67,14 @@ def handle_request(self, request: HttpParser) -> None: elif request.path in ( b'/dashboard', b'/dashboard/proxy.html'): - self.client.queue(build_http_response( + self.client.queue(memoryview(build_http_response( httpStatusCodes.PERMANENT_REDIRECT, reason=b'Permanent Redirect', headers={ b'Location': b'/dashboard/', b'Content-Length': b'0', b'Connection': b'close', } - )) + ))) def on_websocket_open(self) -> None: logger.info('app ws opened') @@ -104,6 +104,6 @@ def on_websocket_close(self) -> None: def reply(self, data: Dict[str, Any]) -> None: self.client.queue( - WebsocketFrame.text( + memoryview(WebsocketFrame.text( bytes_( - json.dumps(data)))) + json.dumps(data))))) diff --git a/proxy/dashboard/inspect_traffic.py b/proxy/dashboard/inspect_traffic.py index 862c5aca14..df82a37f90 100644 --- a/proxy/dashboard/inspect_traffic.py +++ b/proxy/dashboard/inspect_traffic.py @@ -37,12 +37,12 @@ def handle_message(self, message: Dict[str, Any]) -> None: # inspection can only be enabled if --enable-events is used if not self.flags.enable_events: self.client.queue( - WebsocketFrame.text( + memoryview(WebsocketFrame.text( bytes_( json.dumps( {'id': message['id'], 'response': 'not enabled'}) ) - ) + )) ) else: self.subscriber.subscribe( @@ -61,6 +61,6 @@ def handle_message(self, message: Dict[str, Any]) -> None: def callback(client: TcpClientConnection, event: Dict[str, Any]) -> None: event['push'] = 'inspect_traffic' client.queue( - WebsocketFrame.text( + memoryview(WebsocketFrame.text( bytes_( - json.dumps(event)))) + json.dumps(event))))) diff --git a/proxy/dashboard/plugin.py b/proxy/dashboard/plugin.py index 0574bca97c..7c5b9a3b9a 100644 --- a/proxy/dashboard/plugin.py +++ b/proxy/dashboard/plugin.py @@ -51,6 +51,6 @@ def disconnected(self) -> None: def reply(self, data: Dict[str, Any]) -> None: self.client.queue( - WebsocketFrame.text( + memoryview(WebsocketFrame.text( bytes_( - json.dumps(data)))) + json.dumps(data))))) diff --git a/proxy/http/exception.py b/proxy/http/exception.py index 18035388e8..f568394006 100644 --- a/proxy/http/exception.py +++ b/proxy/http/exception.py @@ -24,7 +24,7 @@ class HttpProtocolException(Exception): inherit HttpProtocolException base class. Implement response() method to optionally return custom response to client.""" - def response(self, request: HttpParser) -> Optional[bytes]: + def response(self, request: HttpParser) -> Optional[memoryview]: return None # pragma: no cover @@ -44,21 +44,21 @@ def __init__(self, self.headers: Optional[Dict[bytes, bytes]] = headers self.body: Optional[bytes] = body - def response(self, _request: HttpParser) -> Optional[bytes]: + def response(self, _request: HttpParser) -> Optional[memoryview]: if self.status_code: - return build_http_response( + return memoryview(build_http_response( status_code=self.status_code, reason=self.reason, headers=self.headers, body=self.body - ) + )) return None class ProxyConnectionFailed(HttpProtocolException): """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server.""" - RESPONSE_PKT = build_http_response( + RESPONSE_PKT = memoryview(build_http_response( httpStatusCodes.BAD_GATEWAY, reason=b'Bad Gateway', headers={ @@ -66,14 +66,14 @@ class ProxyConnectionFailed(HttpProtocolException): b'Connection': b'close' }, body=b'Bad Gateway' - ) + )) def __init__(self, host: str, port: int, reason: str): self.host: str = host self.port: int = port self.reason: str = reason - def response(self, _request: HttpParser) -> bytes: + def response(self, _request: HttpParser) -> memoryview: return self.RESPONSE_PKT @@ -81,7 +81,7 @@ class ProxyAuthenticationFailed(HttpProtocolException): """Exception raised when Http Proxy auth is enabled and incoming request doesn't present necessary credentials.""" - RESPONSE_PKT = build_http_response( + RESPONSE_PKT = memoryview(build_http_response( httpStatusCodes.PROXY_AUTH_REQUIRED, reason=b'Proxy Authentication Required', headers={ @@ -89,7 +89,7 @@ class ProxyAuthenticationFailed(HttpProtocolException): b'Proxy-Authenticate': b'Basic', b'Connection': b'close', }, - body=b'Proxy Authentication Required') + body=b'Proxy Authentication Required')) - def response(self, _request: HttpParser) -> bytes: + def response(self, _request: HttpParser) -> memoryview: return self.RESPONSE_PKT diff --git a/proxy/http/handler.py b/proxy/http/handler.py index bf74a5bac5..569a23c929 100644 --- a/proxy/http/handler.py +++ b/proxy/http/handler.py @@ -87,7 +87,7 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: return False # pragma: no cover @abstractmethod - def on_client_data(self, raw: bytes) -> Optional[bytes]: + def on_client_data(self, raw: memoryview) -> Optional[memoryview]: return raw # pragma: no cover @abstractmethod @@ -96,7 +96,7 @@ def on_request_complete(self) -> Union[socket.socket, bool]: return False # pragma: no cover @abstractmethod - def on_response_chunk(self, chunk: bytes) -> bytes: + def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]: """Handle data chunks as received from the server. Return optionally modified chunk to return back to client.""" @@ -215,8 +215,8 @@ def shutdown(self) -> None: logger.debug( 'Closing client connection %r ' - 'at address %r with pending client buffer size %d bytes' % - (self.client.connection, self.client.addr, self.client.buffer_size())) + 'at address %r has buffer %s' % + (self.client.connection, self.client.addr, self.client.has_buffer())) conn = self.client.connection # Unwrap if wrapped before shutdown. @@ -281,10 +281,12 @@ def flush(self) -> None: self.selector.unregister(self.client.connection) def handle_writables(self, writables: List[Union[int, HasFileno]]) -> bool: - if self.client.buffer_size() > 0 and self.client.connection in writables: + if self.client.has_buffer() and self.client.connection in writables: logger.debug('Client is ready for writes, flushing buffer') self.last_activity = time.time() + # TODO(abhinavsingh): This hook could just reside within server recv block + # instead of invoking when flushed to client. # Invoke plugin.on_response_chunk chunk = self.client.buffer for plugin in self.plugins.values(): @@ -322,7 +324,7 @@ def handle_readables(self, readables: List[Union[int, HasFileno]]) -> bool: (self.client.tag, self.client.connection, e)) return True - if not client_data: + if client_data is None: logger.debug('Client closed connection, tearing down...') self.client.closed = True return True @@ -346,7 +348,9 @@ def handle_readables(self, readables: List[Union[int, HasFileno]]) -> bool: # valid request. if client_data and self.request.state != httpParserStates.COMPLETE: # Parse http request - self.request.parse(client_data) + # TODO(abhinavsingh): Remove .tobytes after parser is + # memoryview compliant + self.request.parse(client_data.tobytes()) if self.request.state == httpParserStates.COMPLETE: # Invoke plugin.on_request_complete for plugin in self.plugins.values(): diff --git a/proxy/http/inspector.py b/proxy/http/inspector.py index b8d44134b2..662bf894ee 100644 --- a/proxy/http/inspector.py +++ b/proxy/http/inspector.py @@ -112,7 +112,7 @@ def handle_devtools_message(self, message: Dict[str, Any]) -> None: data['id'] = message['id'] frame.data = bytes_(json.dumps(data)) - self.client.queue(frame.build()) + self.client.queue(memoryview(frame.build())) class CoreEventsToDevtoolsProtocol: @@ -136,9 +136,9 @@ def transformer(client: TcpClientConnection, # drop core events unrelated to Devtools return client.queue( - WebsocketFrame.text( + memoryview(WebsocketFrame.text( bytes_( - json.dumps(data)))) + json.dumps(data))))) @staticmethod def request_complete(event: Dict[str, Any]) -> Dict[str, Any]: diff --git a/proxy/http/parser.py b/proxy/http/parser.py index 24b410cb40..c30cc8aef8 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -117,7 +117,9 @@ def set_line_attributes(self) -> None: self.host, self.port = self.url.hostname, self.url.port \ if self.url.port else 80 else: - raise KeyError('Invalid request. Method: %r, Url: %r' % (self.method, self.url)) + raise KeyError( + 'Invalid request. Method: %r, Url: %r' % + (self.method, self.url)) self.path = self.build_url() def is_chunked_encoded(self) -> bool: diff --git a/proxy/http/proxy.py b/proxy/http/proxy.py index 9dfe836e4a..1ea74e2fb9 100644 --- a/proxy/http/proxy.py +++ b/proxy/http/proxy.py @@ -88,7 +88,7 @@ def handle_client_request( return request # pragma: no cover @abstractmethod - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: """Handler called right after receiving raw response from upstream server. For HTTPS connections, chunk will be encrypted unless @@ -104,10 +104,10 @@ def on_upstream_connection_close(self) -> None: class HttpProxyPlugin(HttpProtocolHandlerPlugin): """HttpProtocolHandler plugin which implements HttpProxy specifications.""" - PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = build_http_response( + PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = memoryview(build_http_response( httpStatusCodes.OK, reason=b'Connection established' - ) + )) # Used to synchronize with other HttpProxyPlugin instances while # generating certificates @@ -182,7 +182,7 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: (self.server.tag, self.server.connection, e)) return True - if not raw: + if raw is None: logger.debug('Server closed connection, tearing down...') return True @@ -200,7 +200,9 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: if self.response.state == httpParserStates.COMPLETE: self.handle_pipeline_response(raw) else: - self.response.parse(raw) + # TODO(abhinavsingh): Remove .tobytes after parser is + # memoryview compliant + self.response.parse(raw.tobytes()) self.emit_response_events() else: self.response.total_size += len(raw) @@ -236,10 +238,10 @@ def on_client_connection_close(self) -> None: pass finally: logger.debug( - 'Closed server connection with pending server buffer size %d bytes' % - self.server.buffer_size()) + 'Closed server connection, has buffer %s' % + self.server.has_buffer()) - def on_response_chunk(self, chunk: bytes) -> bytes: + def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]: # TODO: Allow to output multiple access_log lines # for each request over a pipelined HTTP connection (not for HTTPS). # However, this must also be accompanied by resetting both request @@ -250,7 +252,7 @@ def on_response_chunk(self, chunk: bytes) -> bytes: # self.access_log() return chunk - def on_client_data(self, raw: bytes) -> Optional[bytes]: + def on_client_data(self, raw: memoryview) -> Optional[memoryview]: if not self.request.has_upstream_server(): return raw @@ -261,7 +263,9 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: if self.pipeline_request is None: self.pipeline_request = HttpParser( httpParserTypes.REQUEST_PARSER) - self.pipeline_request.parse(raw) + # TODO(abhinavsingh): Remove .tobytes after parser is + # memoryview compliant + self.pipeline_request.parse(raw.tobytes()) if self.pipeline_request.state == httpParserStates.COMPLETE: for plugin in self.plugins.values(): assert self.pipeline_request is not None @@ -270,7 +274,11 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: return None self.pipeline_request = r assert self.pipeline_request is not None - self.server.queue(self.pipeline_request.build()) + # TODO(abhinavsingh): Remove memoryview wrapping here after + # parser is fully memoryview compliant + self.server.queue( + memoryview( + self.pipeline_request.build())) self.pipeline_request = None else: self.server.queue(raw) @@ -347,15 +355,17 @@ def on_request_complete(self) -> Union[socket.socket, bool]: [(b'Via', b'1.1 %s' % PROXY_AGENT_HEADER_VALUE)]) # Disable args.disable_headers before dispatching to upstream self.server.queue( - self.request.build( - disable_headers=self.flags.disable_headers)) + memoryview(self.request.build( + disable_headers=self.flags.disable_headers))) return False - def handle_pipeline_response(self, raw: bytes) -> None: + def handle_pipeline_response(self, raw: memoryview) -> None: if self.pipeline_response is None: self.pipeline_response = HttpParser( httpParserTypes.RESPONSE_PARSER) - self.pipeline_response.parse(raw) + # TODO(abhinavsingh): Remove .tobytes after parser is memoryview + # compliant + self.pipeline_response.parse(raw.tobytes()) if self.pipeline_response.state == httpParserStates.COMPLETE: self.pipeline_response = None diff --git a/proxy/http/server.py b/proxy/http/server.py index cfa3288934..8120718a21 100644 --- a/proxy/http/server.py +++ b/proxy/http/server.py @@ -85,7 +85,7 @@ class HttpWebServerPacFilePlugin(HttpWebServerBasePlugin): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.pac_file_response: Optional[bytes] = None + self.pac_file_response: Optional[memoryview] = None self.cache_pac_file_response() def routes(self) -> List[Tuple[int, bytes]]: @@ -116,30 +116,30 @@ def cache_pac_file_response(self) -> None: content = f.read() except IOError: content = bytes_(self.flags.pac_file) - self.pac_file_response = build_http_response( + self.pac_file_response = memoryview(build_http_response( 200, reason=b'OK', headers={ b'Content-Type': b'application/x-ns-proxy-autoconfig', b'Content-Encoding': b'gzip', }, body=gzip.compress(content) - ) + )) class HttpWebServerPlugin(HttpProtocolHandlerPlugin): """HttpProtocolHandler plugin which handles incoming requests to local web server.""" - DEFAULT_404_RESPONSE = build_http_response( + DEFAULT_404_RESPONSE = memoryview(build_http_response( httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', headers={b'Server': PROXY_AGENT_HEADER_VALUE, b'Connection': b'close'} - ) + )) - DEFAULT_501_RESPONSE = build_http_response( + DEFAULT_501_RESPONSE = memoryview(build_http_response( httpStatusCodes.NOT_IMPLEMENTED, reason=b'NOT IMPLEMENTED', headers={b'Server': PROXY_AGENT_HEADER_VALUE, b'Connection': b'close'} - ) + )) def __init__( self, @@ -166,13 +166,13 @@ def __init__( self.routes[protocol][path] = instance @staticmethod - def read_and_build_static_file_response(path: str) -> bytes: + def read_and_build_static_file_response(path: str) -> memoryview: with open(path, 'rb') as f: content = f.read() content_type = mimetypes.guess_type(path)[0] if content_type is None: content_type = 'text/plain' - return build_http_response( + return memoryview(build_http_response( httpStatusCodes.OK, reason=b'OK', headers={ @@ -181,7 +181,7 @@ def read_and_build_static_file_response(path: str) -> bytes: b'Content-Encoding': b'gzip', b'Connection': b'close', }, - body=gzip.compress(content)) + body=gzip.compress(content))) def serve_file_or_404(self, path: str) -> bool: """Read and serves a file from disk. @@ -202,9 +202,9 @@ def try_upgrade(self) -> bool: if self.request.has_header(b'upgrade') and \ self.request.header(b'upgrade').lower() == b'websocket': self.client.queue( - build_websocket_handshake_response( + memoryview(build_websocket_handshake_response( WebsocketFrame.key_to_accept( - self.request.header(b'Sec-WebSocket-Key')))) + self.request.header(b'Sec-WebSocket-Key'))))) self.switched_protocol = httpProtocolTypes.WEBSOCKET else: self.client.queue(self.DEFAULT_501_RESPONSE) @@ -257,9 +257,11 @@ def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool: def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: pass - def on_client_data(self, raw: bytes) -> Optional[bytes]: + def on_client_data(self, raw: memoryview) -> Optional[memoryview]: if self.switched_protocol == httpProtocolTypes.WEBSOCKET: - remaining = raw + # TODO(abhinavsingh): Remove .tobytes after websocket frame parser + # is memoryview compliant + remaining = raw.tobytes() frame = WebsocketFrame() while remaining != b'': # TODO: Teardown if invalid protocol exception @@ -283,7 +285,9 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: if self.pipeline_request is None: self.pipeline_request = HttpParser( httpParserTypes.REQUEST_PARSER) - self.pipeline_request.parse(raw) + # TODO(abhinavsingh): Remove .tobytes after parser is memoryview + # compliant + self.pipeline_request.parse(raw.tobytes()) if self.pipeline_request.state == httpParserStates.COMPLETE: self.route.handle_request(self.pipeline_request) if not self.pipeline_request.is_http_1_1_keep_alive(): @@ -293,7 +297,7 @@ def on_client_data(self, raw: bytes) -> Optional[bytes]: self.pipeline_request = None return raw - def on_response_chunk(self, chunk: bytes) -> bytes: + def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]: return chunk def on_client_connection_close(self) -> None: diff --git a/proxy/http/websocket.py b/proxy/http/websocket.py index 2408099a95..68294114ab 100644 --- a/proxy/http/websocket.py +++ b/proxy/http/websocket.py @@ -234,12 +234,14 @@ def run_once(self) -> bool: for key, mask in events: if mask & selectors.EVENT_READ and self.on_message: raw = self.recv() - if raw is None or raw == b'': + if raw is None or raw.tobytes() == b'': self.closed = True logger.debug('Websocket connection closed by server') return True frame = WebsocketFrame() - frame.parse(raw) + # TODO(abhinavsingh): Remove .tobytes after parser is + # memoryview compliant + frame.parse(raw.tobytes()) self.on_message(frame) elif mask & selectors.EVENT_WRITE: logger.debug(self.buffer) diff --git a/proxy/plugin/cache/base.py b/proxy/plugin/cache/base.py index 5a9f5fed8a..2061bef0a6 100644 --- a/proxy/plugin/cache/base.py +++ b/proxy/plugin/cache/base.py @@ -50,7 +50,7 @@ def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: assert self.store return self.store.cache_request(request) - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: assert self.store return self.store.cache_response_chunk(chunk) diff --git a/proxy/plugin/cache/store/base.py b/proxy/plugin/cache/store/base.py index e382a96ab0..d15c5a6da3 100644 --- a/proxy/plugin/cache/store/base.py +++ b/proxy/plugin/cache/store/base.py @@ -28,7 +28,7 @@ def cache_request(self, request: HttpParser) -> Optional[HttpParser]: return request @abstractmethod - def cache_response_chunk(self, chunk: bytes) -> bytes: + def cache_response_chunk(self, chunk: memoryview) -> memoryview: return chunk @abstractmethod diff --git a/proxy/plugin/cache/store/disk.py b/proxy/plugin/cache/store/disk.py index 3de1b073c4..cf108c61e0 100644 --- a/proxy/plugin/cache/store/disk.py +++ b/proxy/plugin/cache/store/disk.py @@ -37,9 +37,9 @@ def open(self, request: HttpParser) -> None: def cache_request(self, request: HttpParser) -> Optional[HttpParser]: return request - def cache_response_chunk(self, chunk: bytes) -> bytes: + def cache_response_chunk(self, chunk: memoryview) -> memoryview: if self.cache_file: - self.cache_file.write(chunk) + self.cache_file.write(chunk.tobytes()) return chunk def close(self) -> None: diff --git a/proxy/plugin/filter_by_upstream.py b/proxy/plugin/filter_by_upstream.py index 759af8f963..a919bd15be 100644 --- a/proxy/plugin/filter_by_upstream.py +++ b/proxy/plugin/filter_by_upstream.py @@ -36,7 +36,7 @@ def handle_client_request( self, request: HttpParser) -> Optional[HttpParser]: return request - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: return chunk def on_upstream_connection_close(self) -> None: diff --git a/proxy/plugin/man_in_the_middle.py b/proxy/plugin/man_in_the_middle.py index 2a16ed64da..cc3ab63e7e 100644 --- a/proxy/plugin/man_in_the_middle.py +++ b/proxy/plugin/man_in_the_middle.py @@ -27,10 +27,10 @@ def handle_client_request( self, request: HttpParser) -> Optional[HttpParser]: return request - def handle_upstream_chunk(self, chunk: bytes) -> bytes: - return build_http_response( + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: + return memoryview(build_http_response( httpStatusCodes.OK, - reason=b'OK', body=b'Hello from man in the middle') + reason=b'OK', body=b'Hello from man in the middle')) def on_upstream_connection_close(self) -> None: pass diff --git a/proxy/plugin/mock_rest_api.py b/proxy/plugin/mock_rest_api.py index 1415a456ac..270c864408 100644 --- a/proxy/plugin/mock_rest_api.py +++ b/proxy/plugin/mock_rest_api.py @@ -67,21 +67,21 @@ def handle_client_request( return request assert request.path if request.path in self.REST_API_SPEC: - self.client.queue(build_http_response( + self.client.queue(memoryview(build_http_response( httpStatusCodes.OK, reason=b'OK', headers={b'Content-Type': b'application/json'}, body=bytes_(json.dumps( self.REST_API_SPEC[request.path])) - )) + ))) else: - self.client.queue(build_http_response( + self.client.queue(memoryview(build_http_response( httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', body=b'Not Found' - )) + ))) return None - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: return chunk def on_upstream_connection_close(self) -> None: diff --git a/proxy/plugin/modify_post_data.py b/proxy/plugin/modify_post_data.py index bdc4550a80..98b89daf5c 100644 --- a/proxy/plugin/modify_post_data.py +++ b/proxy/plugin/modify_post_data.py @@ -40,7 +40,7 @@ def handle_client_request( request.add_header(b'Content-Type', b'application/json') return request - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: return chunk def on_upstream_connection_close(self) -> None: diff --git a/proxy/plugin/redirect_to_custom_server.py b/proxy/plugin/redirect_to_custom_server.py index cea287ce03..d2118e5fea 100644 --- a/proxy/plugin/redirect_to_custom_server.py +++ b/proxy/plugin/redirect_to_custom_server.py @@ -38,7 +38,7 @@ def handle_client_request( self, request: HttpParser) -> Optional[HttpParser]: return request - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: return chunk def on_upstream_connection_close(self) -> None: diff --git a/proxy/plugin/shortlink.py b/proxy/plugin/shortlink.py index 6cca81daa1..309fc1fbc2 100644 --- a/proxy/plugin/shortlink.py +++ b/proxy/plugin/shortlink.py @@ -58,26 +58,26 @@ def handle_client_request( if request.host and request.host != b'localhost' and DOT not in request.host: if request.host in self.SHORT_LINKS: path = SLASH if not request.path else request.path - self.client.queue(build_http_response( + self.client.queue(memoryview(build_http_response( httpStatusCodes.SEE_OTHER, reason=b'See Other', headers={ b'Location': b'http://' + self.SHORT_LINKS[request.host] + path, b'Content-Length': b'0', b'Connection': b'close', } - )) + ))) else: - self.client.queue(build_http_response( + self.client.queue(memoryview(build_http_response( httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND', headers={ b'Content-Length': b'0', b'Connection': b'close', } - )) + ))) return None return request - def handle_upstream_chunk(self, chunk: bytes) -> bytes: + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: return chunk def on_upstream_connection_close(self) -> None: diff --git a/proxy/plugin/web_server_route.py b/proxy/plugin/web_server_route.py index dd52658f87..48f917a729 100644 --- a/proxy/plugin/web_server_route.py +++ b/proxy/plugin/web_server_route.py @@ -32,11 +32,11 @@ def routes(self) -> List[Tuple[int, bytes]]: def handle_request(self, request: HttpParser) -> None: if request.path == b'/http-route-example': - self.client.queue(build_http_response( - httpStatusCodes.OK, body=b'HTTP route response')) + self.client.queue(memoryview(build_http_response( + httpStatusCodes.OK, body=b'HTTP route response'))) elif request.path == b'/https-route-example': - self.client.queue(build_http_response( - httpStatusCodes.OK, body=b'HTTPS route response')) + self.client.queue(memoryview(build_http_response( + httpStatusCodes.OK, body=b'HTTPS route response'))) def on_websocket_open(self) -> None: logger.info('Websocket open') diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py index 8f5f5a1dbe..67341bb2ea 100644 --- a/proxy/testing/test_case.py +++ b/proxy/testing/test_case.py @@ -13,6 +13,7 @@ from typing import Optional, List, Generator, Any from ..proxy import Proxy +from ..common.constants import DEFAULT_TIMEOUT from ..common.utils import get_available_port, new_socket_connection from ..plugin import CacheResponsesPlugin @@ -40,14 +41,16 @@ def setUpClass(cls) -> None: cls.INPUT_ARGS.append(str(cls.PROXY_PORT)) cls.PROXY = Proxy(input_args=cls.INPUT_ARGS) - cls.PROXY.flags.plugins[b'HttpProxyBasePlugin'].append(CacheResponsesPlugin) + cls.PROXY.flags.plugins[b'HttpProxyBasePlugin'].append( + CacheResponsesPlugin) cls.PROXY.__enter__() cls.wait_for_server(cls.PROXY_PORT) @staticmethod - def wait_for_server(proxy_port: int) -> None: + def wait_for_server(proxy_port: int, wait_for_seconds: int = DEFAULT_TIMEOUT) -> None: """Wait for proxy.py server to come up.""" + start_time = time.time() while True: try: conn = new_socket_connection( @@ -57,6 +60,9 @@ def wait_for_server(proxy_port: int) -> None: except ConnectionRefusedError: time.sleep(0.1) + if time.time() - start_time > wait_for_seconds: + raise TimeoutError('Timed out while waiting for proxy.py to start...') + @classmethod def tearDownClass(cls) -> None: assert cls.PROXY diff --git a/tests/common/test_pki.py b/tests/common/test_pki.py index a896de1ecb..d3796787bf 100644 --- a/tests/common/test_pki.py +++ b/tests/common/test_pki.py @@ -22,13 +22,24 @@ def test_run_openssl_command(self, mock_popen: mock.Mock) -> None: command = ['my', 'custom', 'command'] mock_popen.return_value.returncode = 0 self.assertTrue(pki.run_openssl_command(command, 10)) - mock_popen.assert_called_with(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + mock_popen.assert_called_with( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) def test_get_ext_config(self) -> None: self.assertEqual(pki.get_ext_config(None, None), b'') self.assertEqual(pki.get_ext_config([], None), b'') - self.assertEqual(pki.get_ext_config(['proxy.py'], None), b'\nsubjectAltName=DNS:proxy.py') - self.assertEqual(pki.get_ext_config(None, 'serverAuth'), b'\nextendedKeyUsage=serverAuth') + self.assertEqual( + pki.get_ext_config( + ['proxy.py'], + None), + b'\nsubjectAltName=DNS:proxy.py') + self.assertEqual( + pki.get_ext_config( + None, + 'serverAuth'), + b'\nextendedKeyUsage=serverAuth') self.assertEqual(pki.get_ext_config(['proxy.py'], 'serverAuth'), b'\nsubjectAltName=DNS:proxy.py\nextendedKeyUsage=serverAuth') self.assertEqual(pki.get_ext_config(['proxy.py', 'www.proxy.py'], 'serverAuth'), @@ -44,7 +55,10 @@ def test_ssl_config(self) -> None: with pki.ssl_config(['proxy.py']) as (config_path, has_extension): self.assertTrue(has_extension) with open(config_path, 'rb') as config: - self.assertEqual(config.read(), pki.DEFAULT_CONFIG + b'\n[PROXY]\nsubjectAltName=DNS:proxy.py') + self.assertEqual( + config.read(), + pki.DEFAULT_CONFIG + + b'\n[PROXY]\nsubjectAltName=DNS:proxy.py') def test_extfile_no_ext(self) -> None: with pki.ext_file() as config_path: @@ -54,7 +68,9 @@ def test_extfile_no_ext(self) -> None: def test_extfile(self) -> None: with pki.ext_file(['proxy.py']) as config_path: with open(config_path, 'rb') as config: - self.assertEqual(config.read(), b'\nsubjectAltName=DNS:proxy.py') + self.assertEqual( + config.read(), + b'\nsubjectAltName=DNS:proxy.py') def test_gen_private_key(self) -> None: pass diff --git a/tests/common/test_utils.py b/tests/common/test_utils.py index 5db1d508b4..27ada7ba9f 100644 --- a/tests/common/test_utils.py +++ b/tests/common/test_utils.py @@ -12,7 +12,7 @@ import unittest from unittest import mock -from proxy.common.constants import DEFAULT_IPV6_HOSTNAME, DEFAULT_IPV4_HOSTNAME, DEFAULT_PORT +from proxy.common.constants import DEFAULT_IPV6_HOSTNAME, DEFAULT_IPV4_HOSTNAME, DEFAULT_PORT, DEFAULT_TIMEOUT from proxy.common.utils import new_socket_connection, socket_connection @@ -41,7 +41,7 @@ def test_new_socket_connection_ipv6(self, mock_socket: mock.Mock) -> None: @mock.patch('socket.create_connection') def test_new_socket_connection_dual(self, mock_socket: mock.Mock) -> None: conn = new_socket_connection(self.addr_dual) - mock_socket.assert_called_with(self.addr_dual) + mock_socket.assert_called_with(self.addr_dual, timeout=DEFAULT_TIMEOUT) self.assertEqual(conn, mock_socket.return_value) @mock.patch('proxy.common.utils.new_socket_connection') diff --git a/tests/http/test_protocol_handler.py b/tests/http/test_protocol_handler.py index 4113ee05fe..1356e9c55a 100644 --- a/tests/http/test_protocol_handler.py +++ b/tests/http/test_protocol_handler.py @@ -86,7 +86,7 @@ def assert_tunnel_response( self.assertTrue( cast(HttpProxyPlugin, self.protocol_handler.plugins['HttpProxyPlugin']).server is not None) self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0], HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT) mock_server_connection.assert_called_once() server.connect.assert_called_once() @@ -94,7 +94,7 @@ def assert_tunnel_response( server.closed = False parser = HttpParser(httpParserTypes.RESPONSE_PARSER) - parser.parse(self.protocol_handler.client.buffer) + parser.parse(self.protocol_handler.client.buffer[0].tobytes()) self.assertEqual(parser.state, httpParserStates.COMPLETE) assert parser.code is not None self.assertEqual(int(parser.code), 200) @@ -158,7 +158,7 @@ def test_proxy_connection_failed(self) -> None: ]) self.protocol_handler.run_once() self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0], ProxyConnectionFailed.RESPONSE_PKT) @mock.patch('selectors.DefaultSelector') @@ -184,7 +184,7 @@ def test_proxy_authentication_failed( ]) self.protocol_handler.run_once() self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0], ProxyAuthenticationFailed.RESPONSE_PKT) @mock.patch('selectors.DefaultSelector') diff --git a/tests/http/test_web_server.py b/tests/http/test_web_server.py index 6b2148c07f..aeea532351 100644 --- a/tests/http/test_web_server.py +++ b/tests/http/test_web_server.py @@ -107,7 +107,7 @@ def test_default_web_server_returns_404( self.protocol_handler.request.state, httpParserStates.COMPLETE) self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0], HttpWebServerPlugin.DEFAULT_404_RESPONSE) @mock.patch('selectors.DefaultSelector') diff --git a/tests/plugin/test_http_proxy_plugins.py b/tests/plugin/test_http_proxy_plugins.py index b347a880b8..b17d5729e5 100644 --- a/tests/plugin/test_http_proxy_plugins.py +++ b/tests/plugin/test_http_proxy_plugins.py @@ -112,7 +112,7 @@ def test_proposed_rest_api_plugin( mock_server_conn.assert_not_called() self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0].tobytes(), build_http_response( httpStatusCodes.OK, reason=b'OK', headers={b'Content-Type': b'application/json'}, @@ -172,7 +172,7 @@ def test_filter_by_upstream_host_plugin( mock_server_conn.assert_not_called() self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0].tobytes(), build_http_response( status_code=httpStatusCodes.I_AM_A_TEAPOT, reason=b'I\'m a tea pot', @@ -247,7 +247,7 @@ def closed() -> bool: reason=b'OK', body=b'Original Response From Upstream') self.protocol_handler.run_once() self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0].tobytes(), build_http_response( httpStatusCodes.OK, reason=b'OK', body=b'Hello from man in the middle') diff --git a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py index 5f2def28d1..2ef478c4e7 100644 --- a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py +++ b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py @@ -135,7 +135,7 @@ def send(raw: bytes) -> int: self._conn.send.assert_called_with( HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT ) - self.assertEqual(self.protocol_handler.client.buffer, b'') + self.assertFalse(self.protocol_handler.client.has_buffer()) def test_modify_post_data_plugin(self) -> None: original = b'{"key": "value"}' @@ -186,7 +186,7 @@ def test_man_in_the_middle_plugin(self) -> None: reason=b'OK', body=b'Original Response From Upstream') self.protocol_handler.run_once() self.assertEqual( - self.protocol_handler.client.buffer, + self.protocol_handler.client.buffer[0].tobytes(), build_http_response( httpStatusCodes.OK, reason=b'OK', body=b'Hello from man in the middle') diff --git a/tests/testing/test_embed.py b/tests/testing/test_embed.py index 43dffbd544..d7032c2f9a 100644 --- a/tests/testing/test_embed.py +++ b/tests/testing/test_embed.py @@ -8,9 +8,11 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -import json +import os +import unittest import http.client import urllib.request +import urllib.error from proxy import TestCase from proxy.common.constants import DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE @@ -19,6 +21,8 @@ from proxy.http.methods import httpMethods +@unittest.skipIf(os.environ.get('GITHUB_ACTIONS', False), + 'Disabled on GitHub actions because proxy.py setup hangs') class TestProxyPyEmbedded(TestCase): """This test case is a demonstration of proxy.TestCase and also serves as integration test suite for proxy.py.""" @@ -70,8 +74,10 @@ def make_http_request_using_proxy(self) -> None: 'http': 'http://localhost:%d' % self.PROXY_PORT, }) opener = urllib.request.build_opener(proxy_handler) - r: http.client.HTTPResponse = opener.open('http://httpbin.org/get') - self.assertEqual(r.status, 200) - data = json.loads(r.read(DEFAULT_CLIENT_RECVBUF_SIZE)) - self.assertEqual(data['args'], {}) - self.assertEqual(data['headers']['Host'], 'httpbin.org') + with self.assertRaises(urllib.error.HTTPError): + r: http.client.HTTPResponse = opener.open( + 'http://localhost:%d/' % + self.PROXY_PORT, timeout=10) + self.assertEqual(r.status, 404) + self.assertEqual(r.headers.get('server'), PROXY_AGENT_HEADER_VALUE) + self.assertEqual(r.headers.get('connection'), b'close') diff --git a/tests/testing/test_test_case.py b/tests/testing/test_test_case.py new file mode 100644 index 0000000000..a3b3e3c76d --- /dev/null +++ b/tests/testing/test_test_case.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest +import proxy + +from proxy.common.utils import get_available_port + + +class TestTestCase(unittest.TestCase): + + def test_wait_for_server(self) -> None: + with self.assertRaises(TimeoutError): + proxy.TestCase.wait_for_server(get_available_port(), wait_for_seconds=1) From 35a88c02353544b05267ae3d0992fad1e187c5e5 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 26 Nov 2019 21:16:08 -0800 Subject: [PATCH 051/107] Cleanup (#194) * Add basic README description for dashboard * Use spaces for all except makefile * enable tests for py 3.5 * Python 3.5 support label * Avoid clash of names --- .editorconfig | 4 ++-- .github/workflows/test-dashboard.yml | 2 +- .github/workflows/test-docker.yml | 2 +- .github/workflows/test-library.yml | 4 ++-- README.md | 1 + dashboard/MANIFEST.in | 3 --- dashboard/README.md | 10 ++++++++++ 7 files changed, 17 insertions(+), 9 deletions(-) delete mode 100644 dashboard/MANIFEST.in diff --git a/.editorconfig b/.editorconfig index 8d6151163d..df9ca4979e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,17 +2,17 @@ root = true [*] charset = utf-8 +indent_style = space end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [Makefile] +indent_style = tab indent_size = 8 [*.py] -indent_style = space indent_size = 4 [*.ts] -indent_style = space indent_size = 2 diff --git a/.github/workflows/test-dashboard.yml b/.github/workflows/test-dashboard.yml index 8489ce1cc1..c18dbee8f3 100644 --- a/.github/workflows/test-dashboard.yml +++ b/.github/workflows/test-dashboard.yml @@ -5,7 +5,7 @@ on: [push] jobs: build: runs-on: ${{ matrix.os }}-latest - name: Node ${{ matrix.node }} on ${{ matrix.os }} + name: Dashboard - Node ${{ matrix.node }} on ${{ matrix.os }} strategy: matrix: os: [macOS, ubuntu, windows] diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 24e8403aee..c20097debb 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -5,7 +5,7 @@ on: [push] jobs: build: runs-on: ${{ matrix.os }}-latest - name: Python ${{ matrix.python }} on ${{ matrix.os }} + name: Docker - Python ${{ matrix.python }} on ${{ matrix.os }} strategy: matrix: os: [ubuntu] diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 459aebbc88..ec5b2d99ec 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -5,11 +5,11 @@ on: [push] jobs: build: runs-on: ${{ matrix.os }}-latest - name: Python ${{ matrix.python }} on ${{ matrix.os }} + name: Library - Python ${{ matrix.python }} on ${{ matrix.os }} strategy: matrix: os: [macOS, ubuntu, windows] - python: [3.6, 3.7] + python: [3.5, 3.6, 3.7] max-parallel: 4 fail-fast: false steps: diff --git a/README.md b/README.md index 58d1c7d074..fe557842b8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ [![Contributions Welcome](https://img.shields.io/static/v1?label=contributions&message=welcome%20%F0%9F%91%8D&color=green)](https://github.com/abhinavsingh/proxy.py/issues) [![Gitter](https://badges.gitter.im/proxy-py/community.svg)](https://gitter.im/proxy-py/community) +[![Python 3.5](https://img.shields.io/badge/python-3.5-blue.svg)](https://www.python.org/downloads/release/python-350/) [![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/) [![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg)](https://www.python.org/downloads/release/python-370/) [![Checked with mypy](https://img.shields.io/static/v1?label=mypy&message=checked&color=blue)](http://mypy-lang.org/) diff --git a/dashboard/MANIFEST.in b/dashboard/MANIFEST.in deleted file mode 100644 index 4ae453e25f..0000000000 --- a/dashboard/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include src/proxy.html -include src/proxy.css -include tsbuild/src/proxy.js diff --git a/dashboard/README.md b/dashboard/README.md index 2ad45876ce..551a8b73d3 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -1 +1,11 @@ # Proxy.py Dashboard + +`Proxy.py dashboard` is a standalone frontend application written in Typescript. + +## Features + +- Standalone frontend framework +- Single page application +- Websocket based API +- Extendable using frontend plugins +- Realtime From c9ce446440f034f6cf27d418551aa3f5f61a086c Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 26 Nov 2019 21:44:20 -0800 Subject: [PATCH 052/107] Add py3.8 support and bump node to 12.x (#195) * Add py3.8 support and bump node to 12.x * Add 10.x, 11.x, 12.x matrix for dashboard testing * Add Python 3.8 support label * Single tested with label --- .github/workflows/test-dashboard.yml | 2 +- .github/workflows/test-docker.yml | 2 +- .github/workflows/test-library.yml | 2 +- README.md | 14 +++----------- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-dashboard.yml b/.github/workflows/test-dashboard.yml index c18dbee8f3..c3711e7401 100644 --- a/.github/workflows/test-dashboard.yml +++ b/.github/workflows/test-dashboard.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [macOS, ubuntu, windows] - node: [10.x] + node: [10.x, 11.x, 12.x] max-parallel: 4 fail-fast: false steps: diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index c20097debb..ddb7f60910 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu] - python: [3.7] + python: [3.8] max-parallel: 1 fail-fast: false steps: diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index ec5b2d99ec..786aa0a82a 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [macOS, ubuntu, windows] - python: [3.5, 3.6, 3.7] + python: [3.5, 3.6, 3.7, 3.8] max-parallel: 4 fail-fast: false steps: diff --git a/README.md b/README.md index fe557842b8..cfb114bfef 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,15 @@ [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) [![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) -[![Tested With MacOS](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) -[![Tested With Ubuntu](https://img.shields.io/static/v1?label=tested%20with&message=Ubuntu%20%F0%9F%96%A5&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) -[![Tested With Windows](https://img.shields.io/static/v1?label=tested%20with&message=Windows%20%F0%9F%92%BB&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) -[![Tested With Android](https://img.shields.io/static/v1?label=tested%20with&message=Android%20%F0%9F%93%B1&color=brightgreen)](https://www.android.com/) -[![Tested With Android Emulator](https://img.shields.io/static/v1?label=tested%20with&message=Android%20Emulator%20%F0%9F%93%B1&color=brightgreen)](https://developer.android.com/studio/run/emulator-networking.html#proxy) -[![Tested With iOS](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20%F0%9F%93%B1&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) -[![Tested With iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20Simulator%20%F0%9F%93%B1&color=brightgreen)](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/Introduction/Introduction.html) +[![Tested With MacOS, Ubuntu, Windows, Android, Android Emulator, iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB%20%7C%20Ubuntu%20%F0%9F%96%A5%20%7C%20Windows%20%F0%9F%92%BB%20%7C%20Android%20%F0%9F%93%B1%20%7C%20Android%20Emulator%20%F0%9F%93%B1%20%7C%20iOS%20%F0%9F%93%B1%20%7C%20iOS%20Simulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) [![Maintenance](https://img.shields.io/static/v1?label=maintained%3F&message=yes&color=green)](https://gitHub.com/abhinavsingh/proxy.py/graphs/commit-activity) [![Ask Me Anything](https://img.shields.io/static/v1?label=need%20help%3F&message=ask&color=green)](https://twitter.com/imoracle) [![Contributions Welcome](https://img.shields.io/static/v1?label=contributions&message=welcome%20%F0%9F%91%8D&color=green)](https://github.com/abhinavsingh/proxy.py/issues) [![Gitter](https://badges.gitter.im/proxy-py/community.svg)](https://gitter.im/proxy-py/community) -[![Python 3.5](https://img.shields.io/badge/python-3.5-blue.svg)](https://www.python.org/downloads/release/python-350/) -[![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/) -[![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg)](https://www.python.org/downloads/release/python-370/) -[![Checked with mypy](https://img.shields.io/static/v1?label=mypy&message=checked&color=blue)](http://mypy-lang.org/) +[![Python 3.5](https://img.shields.io/static/v1?label=Python&message=3.5%20%7C%203.6%20%7C%203.7%20%7C%203.8&color=blue)](https://www.python.org/) +[![Checked with mypy](https://img.shields.io/static/v1?label=MyPy&message=checked&color=blue)](http://mypy-lang.org/) [![Become a Backer](https://opencollective.com/proxypy/tiers/backer.svg?avatarHeight=72)](https://opencollective.com/proxypy) From ae92adc432a143a9056eb2251f0cd8a40bcd8f64 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 26 Nov 2019 22:43:05 -0800 Subject: [PATCH 053/107] autopep8 (#196) * autopep8 * Update TestCase section --- README.md | 9 +++++++-- proxy/plugin/cache/base.py | 3 ++- proxy/plugin/cache/cache_responses.py | 3 ++- proxy/testing/test_case.py | 6 ++++-- tests/testing/test_test_case.py | 3 ++- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cfb114bfef..7d7d24edf7 100644 --- a/README.md +++ b/README.md @@ -809,10 +809,12 @@ Note that: 1. `proxy.TestCase` overrides `unittest.TestCase.run()` method to setup and teardown `proxy.py`. 2. `proxy.py` server will listen on a random available port on the system. - This random port is available as `self.proxy_port` within your test cases. + This random port is available as `self.PROXY_PORT` within your test cases. 3. Only a single worker is started by default (`--num-workers 1`) for faster setup and teardown. 4. Most importantly, `proxy.TestCase` also ensures `proxy.py` server - is up and running before proceeding with execution of tests. + is up and running before proceeding with execution of tests. By default, + `proxy.TestCase` will wait for `10 seconds` for `proxy.py` server to start, + upon failure a `TimeoutError` exception will be raised. ## Override startup flags @@ -857,6 +859,9 @@ class TestProxyPyEmbedded(unittest.TestCase): super().run(result) ``` +or simply setup / teardown `proxy.py` within +`setUpClass` and `teardownClass` class methods. + Plugin Developer and Contributor Guide ====================================== diff --git a/proxy/plugin/cache/base.py b/proxy/plugin/cache/base.py index 2061bef0a6..81a2ef65f9 100644 --- a/proxy/plugin/cache/base.py +++ b/proxy/plugin/cache/base.py @@ -46,7 +46,8 @@ def before_upstream_connection( logger.info('Caching disabled due to exception message %s', str(e)) return request - def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: assert self.store return self.store.cache_request(request) diff --git a/proxy/plugin/cache/cache_responses.py b/proxy/plugin/cache/cache_responses.py index 2ad6926d26..91f2907901 100644 --- a/proxy/plugin/cache/cache_responses.py +++ b/proxy/plugin/cache/cache_responses.py @@ -24,5 +24,6 @@ class CacheResponsesPlugin(BaseCacheResponsesPlugin): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.disk_store = OnDiskCacheStore(uid=self.uid, cache_dir=tempfile.gettempdir()) + self.disk_store = OnDiskCacheStore( + uid=self.uid, cache_dir=tempfile.gettempdir()) self.set_store(self.disk_store) diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py index 67341bb2ea..cc261f07a5 100644 --- a/proxy/testing/test_case.py +++ b/proxy/testing/test_case.py @@ -48,7 +48,8 @@ def setUpClass(cls) -> None: cls.wait_for_server(cls.PROXY_PORT) @staticmethod - def wait_for_server(proxy_port: int, wait_for_seconds: int = DEFAULT_TIMEOUT) -> None: + def wait_for_server(proxy_port: int, + wait_for_seconds: int = DEFAULT_TIMEOUT) -> None: """Wait for proxy.py server to come up.""" start_time = time.time() while True: @@ -61,7 +62,8 @@ def wait_for_server(proxy_port: int, wait_for_seconds: int = DEFAULT_TIMEOUT) -> time.sleep(0.1) if time.time() - start_time > wait_for_seconds: - raise TimeoutError('Timed out while waiting for proxy.py to start...') + raise TimeoutError( + 'Timed out while waiting for proxy.py to start...') @classmethod def tearDownClass(cls) -> None: diff --git a/tests/testing/test_test_case.py b/tests/testing/test_test_case.py index a3b3e3c76d..c1dafa07bb 100644 --- a/tests/testing/test_test_case.py +++ b/tests/testing/test_test_case.py @@ -18,4 +18,5 @@ class TestTestCase(unittest.TestCase): def test_wait_for_server(self) -> None: with self.assertRaises(TimeoutError): - proxy.TestCase.wait_for_server(get_available_port(), wait_for_seconds=1) + proxy.TestCase.wait_for_server( + get_available_port(), wait_for_seconds=1) From c6da3324e09b8784b440caa170e22dd8e97061a1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 27 Nov 2019 19:32:54 +0200 Subject: [PATCH 054/107] Update pytest from 5.3.0 to 5.3.1 (#197) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 5d783ac8a7..640af313d3 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,7 +1,7 @@ python-coveralls==2.9.3 coverage==4.5.4 flake8==3.7.9 -pytest==5.3.0 +pytest==5.3.1 pytest-cov==2.8.1 autopep8==1.4.4 mypy==0.740 From 81bf006a7f44eb46a077eb065d15ee2b50ccd355 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 28 Nov 2019 18:41:45 +0200 Subject: [PATCH 055/107] Update twine from 3.1.0 to 3.1.1 (#200) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index 9265a0039a..abec919c24 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ -twine==3.1.0 +twine==3.1.1 wheel==0.33.6 setuptools==42.0.1 From b88d480cdd47ce1d06642226c2fad470495c004c Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 29 Nov 2019 21:28:31 -0800 Subject: [PATCH 056/107] Add reverse proxy example (#201) * Add reverse proxy example * Add separate sections for http proxy and web server plugins * Add doc --- README.md | 88 +++++++++++++++++++++++++++----- proxy/plugin/__init__.py | 2 + proxy/plugin/reverse_proxy.py | 72 ++++++++++++++++++++++++++ proxy/plugin/web_server_route.py | 2 +- 4 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 proxy/plugin/reverse_proxy.py diff --git a/README.md b/README.md index 7d7d24edf7..b46a4a525a 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,17 @@ Table of Contents * [Development version](#build-development-version-locally) * [Customize Startup Flags](#customize-startup-flags) * [Plugin Examples](#plugin-examples) - * [ShortLinkPlugin](#shortlinkplugin) - * [ModifyPostDataPlugin](#modifypostdataplugin) - * [MockRestApiPlugin](#mockrestapiplugin) - * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) - * [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin) - * [CacheResponsesPlugin](#cacheresponsesplugin) - * [ManInTheMiddlePlugin](#maninthemiddleplugin) + * [HTTP Proxy Plugins](#http-proxy-plugins) + * [ShortLinkPlugin](#shortlinkplugin) + * [ModifyPostDataPlugin](#modifypostdataplugin) + * [MockRestApiPlugin](#mockrestapiplugin) + * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) + * [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin) + * [CacheResponsesPlugin](#cacheresponsesplugin) + * [ManInTheMiddlePlugin](#maninthemiddleplugin) + * [HTTP Web Server Plugins](#http-web-server-plugins) + * [Reverse Proxy](#reverse-proxy) + * [Web Server Route](#web-server-route) * [Plugin Ordering](#plugin-ordering) * [End-to-End Encryption](#end-to-end-encryption) * [TLS Interception](#tls-interception) @@ -304,7 +308,9 @@ Plugin Examples - Plugin examples are also bundled with Docker image. - See [Customize startup flags](#customize-startup-flags) to try plugins with Docker image. -## ShortLinkPlugin +## HTTP Proxy Plugins + +### ShortLinkPlugin Add support for short links in your favorite browsers / applications. @@ -333,7 +339,7 @@ w/ | web.whatsapp.com y/ | youtube.com proxy/ | localhost:8899 -## ModifyPostDataPlugin +### ModifyPostDataPlugin Modifies POST request body before sending request to upstream server. @@ -385,7 +391,7 @@ Note following from the response above: 3. Our plugin also added a `Content-Length` header to match length of modified body. -## MockRestApiPlugin +### MockRestApiPlugin Mock responses for your server REST API. Use to test and develop client side applications @@ -416,7 +422,7 @@ the server connection was never made, since response was returned by our plugin. Now modify `ProposedRestApiPlugin` to returns REST API mock responses as expected by your clients. -## RedirectToCustomServerPlugin +### RedirectToCustomServerPlugin Redirects all incoming `http` requests to custom web server. By default, it redirects client requests to inbuilt web server, @@ -451,7 +457,7 @@ Along with the proxy request log, you must also see a http web server request lo 2019-09-24 19:09:33,603 - INFO - pid:49995 - access_log:1157 - ::1:49524 - GET localhost:8899/ - 404 NOT FOUND - 70 bytes ``` -## FilterByUpstreamHostPlugin +### FilterByUpstreamHostPlugin Drops traffic by inspecting upstream host. By default, plugin drops traffic for `google.com` and `www.google.com`. @@ -485,7 +491,7 @@ Traceback (most recent call last): 2019-09-24 19:21:37,897 - INFO - pid:50074 - access_log:1157 - ::1:49911 - GET None:None/ - None None - 0 bytes ``` -## CacheResponsesPlugin +### CacheResponsesPlugin Caches Upstream Server Responses. @@ -561,7 +567,7 @@ Connection: keep-alive } ``` -## ManInTheMiddlePlugin +### ManInTheMiddlePlugin Modifies upstream server responses. @@ -585,6 +591,60 @@ Hello from man in the middle Response body `Hello from man in the middle` is sent by our plugin. +## HTTP Web Server Plugins + +### Reverse Proxy + +Extend in-built Web Server to add Reverse Proxy capabilities. + +Start `proxy.py` as: + +``` +$ proxy \ + --plugins proxy.plugin.ReverseProxyPlugin +``` + +With default configuration, `ReverseProxyPlugin` plugin is equivalent to +following `Nginx` config: + +``` +location /get { + proxy_pass http://httpbin.org/get +} +``` + +Verify using `curl -v localhost:8899/get`: + +``` +{ + "args": {}, + "headers": { + "Accept": "*/*", + "Host": "localhost", + "User-Agent": "curl/7.64.1" + }, + "origin": "1.2.3.4, 5.6.7.8", + "url": "https://localhost/get" +} +``` + +### Web Server Route + +Demonstrates inbuilt web server routing using plugin. + +Start `proxy.py` as: + +``` +$ proxy \ + --plugins proxy.plugin.WebServerPlugin +``` + +Verify using `curl -v localhost:8899/http-route-example`, should return: + +``` +HTTP route response +``` + ## Plugin Ordering When using multiple plugins, depending upon plugin functionality, diff --git a/proxy/plugin/__init__.py b/proxy/plugin/__init__.py index 480c74d3e5..b31a4567e2 100644 --- a/proxy/plugin/__init__.py +++ b/proxy/plugin/__init__.py @@ -16,6 +16,7 @@ from .redirect_to_custom_server import RedirectToCustomServerPlugin from .shortlink import ShortLinkPlugin from .web_server_route import WebServerPlugin +from .reverse_proxy import ReverseProxyPlugin __all__ = [ 'CacheResponsesPlugin', @@ -27,4 +28,5 @@ 'RedirectToCustomServerPlugin', 'ShortLinkPlugin', 'WebServerPlugin', + 'ReverseProxyPlugin', ] diff --git a/proxy/plugin/reverse_proxy.py b/proxy/plugin/reverse_proxy.py new file mode 100644 index 0000000000..59de054454 --- /dev/null +++ b/proxy/plugin/reverse_proxy.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import random +from typing import List, Tuple +from urllib import parse as urlparse + +from ..common.constants import DEFAULT_BUFFER_SIZE +from ..common.utils import socket_connection, text_ +from ..http.parser import HttpParser +from ..http.websocket import WebsocketFrame +from ..http.server import HttpWebServerBasePlugin, httpProtocolTypes + + +class ReverseProxyPlugin(HttpWebServerBasePlugin): + """Extend in-built Web Server to add Reverse Proxy capabilities. + + This example plugin is equivalent to following Nginx configuration: + + location /get { + proxy_pass http://httpbin.org/get + } + + Example: + + $ curl http://localhost:9000/get + { + "args": {}, + "headers": { + "Accept": "*/*", + "Host": "localhost", + "User-Agent": "curl/7.64.1" + }, + "origin": "1.2.3.4, 5.6.7.8", + "url": "https://localhost/get" + } + """ + + REVERSE_PROXY_LOCATION: bytes = b'/get' + REVERSE_PROXY_PASS = [ + b'http://httpbin.org/get' + ] + + def routes(self) -> List[Tuple[int, bytes]]: + return [ + (httpProtocolTypes.HTTP, ReverseProxyPlugin.REVERSE_PROXY_LOCATION), + (httpProtocolTypes.HTTPS, ReverseProxyPlugin.REVERSE_PROXY_LOCATION) + ] + + def handle_request(self, request: HttpParser) -> None: + upstream = random.choice(ReverseProxyPlugin.REVERSE_PROXY_PASS) + url = urlparse.urlsplit(upstream) + assert url.hostname + with socket_connection((text_(url.hostname), url.port if url.port else 80)) as conn: + conn.send(request.build()) + self.client.queue(memoryview(conn.recv(DEFAULT_BUFFER_SIZE))) + + def on_websocket_open(self) -> None: + pass + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + pass + + def on_websocket_close(self) -> None: + pass diff --git a/proxy/plugin/web_server_route.py b/proxy/plugin/web_server_route.py index 48f917a729..84be313302 100644 --- a/proxy/plugin/web_server_route.py +++ b/proxy/plugin/web_server_route.py @@ -21,7 +21,7 @@ class WebServerPlugin(HttpWebServerBasePlugin): - """Demonstration of inbuilt web server routing via plugin.""" + """Demonstrates inbuilt web server routing using plugin.""" def routes(self) -> List[Tuple[int, bytes]]: return [ From 6f4fe34d657b79e98bde141f85b40ef0aace296c Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 30 Nov 2019 09:03:23 -0800 Subject: [PATCH 057/107] Add proxy over ssh tunnel functionality (#198) --- Makefile | 6 ++-- proxy/core/tunnel.py | 62 +++++++++++++++++++++++++++++++++++++++++ requirements-tunnel.txt | 1 + 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 proxy/core/tunnel.py create mode 100644 requirements-tunnel.txt diff --git a/Makefile b/Makefile index c3ae9c7cc7..0e7753b07d 100644 --- a/Makefile +++ b/Makefile @@ -24,10 +24,8 @@ devtools: pushd dashboard && npm run devtools && popd autopep8: - autopep8 --recursive --in-place --aggressive proxy/*.py - autopep8 --recursive --in-place --aggressive proxy/*/*.py - autopep8 --recursive --in-place --aggressive tests/*.py - autopep8 --recursive --in-place --aggressive tests/*/*.py + autopep8 --recursive --in-place --aggressive proxy + autopep8 --recursive --in-place --aggressive tests autopep8 --recursive --in-place --aggressive setup.py https-certificates: diff --git a/proxy/core/tunnel.py b/proxy/core/tunnel.py new file mode 100644 index 0000000000..612fb52d45 --- /dev/null +++ b/proxy/core/tunnel.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +from typing import Tuple, Callable + +import paramiko + + +class Tunnel: + """Establishes a tunnel between local (machine where Tunnel is running) and remote host. + Once a tunnel has been established, remote host can route HTTP(s) traffic to + localhost over tunnel. + """ + + def __init__( + self, + ssh_username: str, + remote_addr: Tuple[str, int], + private_pem_key: str, + remote_proxy_port: int, + conn_handler: Callable[[socket.socket], None]) -> None: + self.remote_addr = remote_addr + self.ssh_username = ssh_username + self.private_pem_key = private_pem_key + self.remote_proxy_port = remote_proxy_port + self.conn_handler = conn_handler + + def run(self) -> None: + ssh = paramiko.SSHClient() + ssh.load_system_host_keys() + ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) + try: + ssh.connect( + hostname=self.remote_addr[0], + port=self.remote_addr[1], + username=self.ssh_username, + key_filename=self.private_pem_key + ) + print('SSH connection established...') + transport = ssh.get_transport() + transport.request_port_forward('', self.remote_proxy_port) + print('Tunnel port forward setup successful...') + while True: + conn: socket.socket = transport.accept(timeout=1) + e = transport.get_exception() + if e: + raise e + if conn is None: + continue + self.conn_handler(conn) + except KeyboardInterrupt: + pass + finally: + ssh.close() diff --git a/requirements-tunnel.txt b/requirements-tunnel.txt new file mode 100644 index 0000000000..6d6a415206 --- /dev/null +++ b/requirements-tunnel.txt @@ -0,0 +1 @@ +paramiko==2.6.0 From dc1d5b68e7294293ddfe64d2cdcdbcbfaeb8458d Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 30 Nov 2019 19:53:13 -0800 Subject: [PATCH 058/107] update mypy to 0.750 (#204) --- requirements-testing.txt | 2 +- tests/http/test_http_parser.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 640af313d3..f6bd2cd7f2 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -4,6 +4,6 @@ flake8==3.7.9 pytest==5.3.1 pytest-cov==2.8.1 autopep8==1.4.4 -mypy==0.740 +mypy==0.750 py-spy==0.3.0 codecov==2.0.15 diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index d99e832822..623af08df1 100644 --- a/tests/http/test_http_parser.py +++ b/tests/http/test_http_parser.py @@ -9,7 +9,6 @@ :license: BSD, see LICENSE for more details. """ import unittest -from typing import Dict, Tuple from proxy.common.constants import CRLF from proxy.common.utils import build_http_request, find_http_line, build_http_response, build_http_header, bytes_ @@ -514,8 +513,3 @@ def test_is_not_http_1_1_keep_alive_for_http_1_0(self) -> None: httpMethods.GET, b'/', protocol_version=b'HTTP/1.0', )) self.assertFalse(self.parser.is_http_1_1_keep_alive()) - - def assertDictContainsSubset(self, subset: Dict[bytes, Tuple[bytes, bytes]], - dictionary: Dict[bytes, Tuple[bytes, bytes]]) -> None: - for k in subset.keys(): - self.assertTrue(k in dictionary) From 654696d2f9883ee24c7b18c9661eeac0cb84429f Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 30 Nov 2019 22:04:43 -0800 Subject: [PATCH 059/107] Test Core Eventing (#205) * Add core event tests * Update .gitignore with coverage * Add shortlink gif * Add event dispatcher test * Test event subscriber --- .gitignore | 1 + README.md | 2 + proxy/core/event.py | 7 ++- shortlink.gif | Bin 0 -> 127216 bytes tests/core/test_event_dispatcher.py | 91 ++++++++++++++++++++++++++++ tests/core/test_event_queue.py | 56 +++++++++++++++++ tests/core/test_event_subscriber.py | 59 ++++++++++++++++++ 7 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 shortlink.gif create mode 100644 tests/core/test_event_dispatcher.py create mode 100644 tests/core/test_event_queue.py create mode 100644 tests/core/test_event_subscriber.py diff --git a/.gitignore b/.gitignore index c9aa49f216..b9aac77751 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .coverage +.coverage.* .idea .vscode .project diff --git a/README.md b/README.md index b46a4a525a..43b1e4fd62 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,8 @@ Plugin Examples Add support for short links in your favorite browsers / applications. +[![Shortlink Plugin](https://raw.githubusercontent.com/abhinavsingh/proxy.py/testit/shortlink.gif)](https://github.com/abhinavsingh/proxy.py#shortlinkplugin) + Start `proxy.py` as: ``` diff --git a/proxy/core/event.py b/proxy/core/event.py index 8bd3b0b5ef..a77ca804a5 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -147,12 +147,15 @@ def handle_event(self, ev: Dict[str, Any]) -> None: for sub_id in unsub_ids: del self.subscribers[sub_id] + def run_once(self) -> None: + ev: Dict[str, Any] = self.event_queue.queue.get(timeout=1) + self.handle_event(ev) + def run(self) -> None: try: while not self.shutdown.is_set(): try: - ev: Dict[str, Any] = self.event_queue.queue.get(timeout=1) - self.handle_event(ev) + self.run_once() except queue.Empty: pass except EOFError: diff --git a/shortlink.gif b/shortlink.gif new file mode 100644 index 0000000000000000000000000000000000000000..433f2d8c29f1e57e6371f456b92ec74d40fc6944 GIT binary patch literal 127216 zcmdqIS5#9``}G?&Bmt6uhzLlDf{03!-U&z(X$n#lG$_3zy-4r9_Y!)C(0lJa6e&uH z^xlz94)1%;cgFwXT%7OrTUmE|jEu4O$g?x&T)!#vR+?Wx=Ql(aa7qvOKVeyy-va`n zP$(re4J{plHvN!3?(Y-tRtBpKMyBV_zWG1r5oNwzEbK2?IbN}IzS8w);oxME*J05M zU}a-tY#>K;?@q?X04KHriABKzm|*+R@Ti#Rf{vK@r1+$?M09rY>nwChV|s8vdRAUW^-y+RVa|(y zyfFFv`q9FYvSLZ2;=WNm$8ZO=@7LqkJjV`Fn`V_SDq zMsw@PVn<0?M@xHGPtSm6*1*)-;LtF3baa%}Z***Y?5*C|#INy@k%{@8U#{gdGrxZ? z9sGG|`X{{M&pd8^^$3Ui`&U-$@5aAnXQk!F(zT84O?1VUtjNyp-oBOm!O`*I;o;HY z(aG)lcXn#cauz9nIplkl^zf<*w`@LkH&TSum96n%EbUg zj_lLGR|SR{K`hPFi@}_eluIETtFjECLYsz}VZxK~D33RE{OAZtI?d%sIo^cjC|T(~ z#%L9}jjR~eFPbZ{pX?G=;xv5wp2dF;+{jMQP1RgYG_FoqO)~86V@kH}-^fX^sis;^ zMgE&vN)x-=pNn<{8Ly-LC<50r0(s@uGXo_0^RhyvjW@F5)B-nh67A$RauYoJ^YcQXGRQO;E2v#<2}b=#?hyA3-fTjq`X!zQJT zXOl_nO;_`Udo9reoaDcUR`^O;1h-t+j|5P&9knlp$(TK?2wlx+>VpfTjeU!pI zDo6)~en^dxkbFrN-MvIBot;EQrp06Z+ z-*sF=TeDrPX1R&hZ)E$WUThR(?mBH@D%dVJD_cYxcB*<(FL&yfcAfWHkJzquJMp58 z2VLN_tAl=qJ(nXa7yI?$gqT><$uF5SktKty2*wY}%;J*wm*SLe&R288ZZ0-!Ms6;5 zdr0tChf_-U>wjxu_?wF2cVh`M1rd40IgOVcONugo3PYzjnXviDFKUZPK< zNBoxWWsuMN|P@B#rnp49b;fshcr)>rVp1D^7bQta} zxr}&Z)GH|%84+m8$VmU9Pp;bv8dyf6a?#VG_~S4t75a=>=0*R9$PYi`sXMB_^~zCK z|M10BroS?v8c^>qkF9LXW;5#>(E3Lf*E*Zc?ngEFm8c@VQ#gk+q;F7{_dxM`{0{>!JjkN}hLZ&=6Kb36cV=FXwSOC?s!s7(t(vJQhvalqb#AKN{j> z;~Extk0Rq--Y9Y+fmtM9PN9D+vYtDe)o0_Ky5M;Hm}(AxQ@)~E|9IjBcdp3om)ie+ z$BB6+xC&hph%1=LiHyouvNisYDff}Sc@w6okta!8|Iy$0q)>~XP`yKLvMjNx(D0$j zr-Ay(ih`43Q=h`mOL9|nzpIKJVqP(peN0ubJ1(V%v7o@Oro#^NVOFFC-va8V(VYLv zLPNHdpZ%Hc`&3;X@3XDPlcd@I<6lK8Y)4<}&&=qJ4b%t`#8XK0E5E+F3KL^&s`=`3 zE6i&-2AVa#%nVRzgM%1pwrq`1vaaQ2CWt9sk*_R=GM8dw0*yE3RA$c zX$M>TxK-0J-Y|BDp35m-%7V&uf?}ibcb`tm?VEN(ES%Sfzg{$-HXjz3x*nJ;TpilC z+`upm!HEyEQpX7gEq&>$sUXJ69q`JKeG`*ce`geH+oe@w1oI!y!QY&A(15ILMQ9eu z!ZbP=Mau$I2EUOvICQbAl?8u>E|IBJ3cBtrFtqA~>N93Qj#ch5!{XCsf`l|EnvofoV5#5}21!A3>vl3W*~L7|nI&*s+U z-Z+iLS952x7jMX0UyQSLOE5)6X|qvn9j*;j`E2eS{#fEFBj)em-pG9R_wR)hikn+MN~apccD&L*Fh1uICqkNk zeVB+4y*W?EnPI$GneObjtF@6u`3qiRS_3tY{}5W%*8e6Rwi9AbCsb;^M>mM_BZvyS zzp5d_S`bR0K-xi71pzt|>JZr@GNn+Mf0d6)&$`tw52o~mm!Hu)_Ncoa#^yGza*a3+ z9@W5PjgB7vMG_jzKZOk%`qJGLgFkmVlTF0?&?aMnY+9KVN)R9y1&fr8$~_uy|N72* zRxr!`)FdP9n>H$O_-A3Ag}2`K_;T*}96bB_S4!5)t0n2G@YTYlDSIvcnSa}o?d^NB zin62gxj4yB#Jygv+AhK4bP{Xf46G7kCo}RjiGwbUJPIJA;trHS0cFC0I%wX{C<60n zf=mo?b@)g2nja6Qf4nyEAb0S1SnEMO?Ll?sK}qRJ1My^F_Kb-o7=3Sid<5F4Cf=5Y z9kfELvBcFV=mZMB>JDoHcr_J; z1~jxy2Izr-Ti3#e5fGe|Lo$l!07-IS>G1Rr#9l%04Nt1~?&An_!Xlv5_qKoGbxS;^sm zTJB(14#F)C=!m_j9D+a@2W;mPeXs_6$Y=HUyQJJcaNjRX5({dSg(x9J%`9P4IIt@z z!~^5>UKJRI^SeNj?4sZY2)_wju$-l3X$~+9;kSx}SfMTdG80tViz=;!tQ`S0_QTF= z+|Ve(T>#1TsoMuK-eNDHGy-JkWoUr+BKV995PlmVOA6A%39|OVEFQRPqQPxgVsB+n z`3(;ySx?28Xk~#ICC!)*MltUbVpJVtwDM!v?Y?)vx8CXSbVU&jq6xPTn;0aQ{T@-OxI3yMYEr_)UL-;l*6G+OC;8F0ReaIOlq=ExB>+UMX zM39Vz&9*}Alz{LGTf`?ln zA9V>kaCWt_a2$$gqgGN12VS-$?m)qVNhLjz@J@uA@-(nF09s!MpGDY6a>Q-9!@6;| zj|RGnf_h*U&k*cNXnUClyFkJ;I7H3dVH2$cMgaK4K2QluJSqzt z#u9%Egt-&Kr;zX?6g+W1lv04?f|R7%GF<&RB=s#&4oyfl1F9h}x;Djf`z2K*d8?&m$Gdgt!F)E~)rb!qd(-2+EcsR}uVG zSmG*NjCNg2rBh*bVxdNUVO4!$^Y6mez`~Z7MQw61T+eA<#*6Cag9BP&2g<~xAl(XO z^Ib23;a1zFd*E)&?c+$0g21b9XVM*Ni3A$dM^s@V+~pvyfJl85@fONX4ny1#09M3; z!VtOQYv5pHjt35?fdfy6=dES}JCJZY8OUrs9GM7NRVJ1~{G3oWus}gMjX|>~plKvY z4qs?dYuT%MpK3tLax27CCfoyQl?h0BKuCl``0ZN48Zde7j<78}AZ0HHn1>)44S*i) zlT0HVwxUwE1Vn+OseAeUZm-ddQ9uz#;CoWg!XiOIf7BIW0I`aN11`-04YqS!S+hpghDG8B?_95Aj= zl2T~GCU@|gET9JfzN=g-wVY8E4%!#Y^hElY?GtTb{2ug#pjsho5P}bY+)+88B8qsI z1L}bTANmn#Af5GJ| zNkl}i`n1(-DbbseH};GM%0ha|RkGng^?ibm?*jB%1*_d#uH#y5q7vHB;Ew*nPU<#N z;WiSjwl3o~vfwsymo}Q@HrmFv3~TL{C$Y;FMHZ-%!yZ_)d$wQ$u_eI7n!?r$K(vg3 zm*PSfjENPnal-MZg=pwJM<$8G}Q{#z5vZ^geth_NW25Ow!*rdMK#=^%i$(#OTY$X zc|fb3C$?u5NhpUY$K#r$P=r{#Bg}%Y9PbWY^dhJr^_u|bqF?%bE(lwTN{wpOUh^XL z21NMhRXRN(U{wJL3A7dn2Yh(US>^7Jxk}xZAZoKDwt81%?L1JgRnwR}&=fq-+BncG zJlJ+M(7r7J^nE}X*l4O=vU~&-H+9&S23nJ5T-enVV%mvRmjp#6bL(=EDNl!!2um`&_gtAycOE-Zc~wCxik)}+XBZ4RTqW# z#0e20DH0|})UAyh=<`9Zsmdtg!s zf@)H~JwB^aM8qQg2(nA+=U3$IegS!T4!Nt{9-a=yxwlNA!FTS0vgF(gi~1`w!HH-> zwmP_y8KLDkaTAh=zNqa}$}g>*wlA=DRMW4otdlx(zw|^V^|fU+kj8QmqI34;rjH;s zDEKZ0Ufl|D#SnL*%1zdwVOXFUhS)|P-b^JHQrNN1l)FV*D^BB5PD}*a04HPM6QmGt zBz!N&&K2j?iwyVqMC{E0*1!Vwk^<+GjKkmmLh%@QEEeb++#7gB66{ioC4_rmfgU(0o|L2kXFbm0xATZZ zA767D)c7YOz0>6Hf4)q`p}~%SITafw?gaz6m(ormr`iyZPM5(mv%!m>3zyo1SJw+S zw77E^?j9Si|K9`0$F4-KwjL_OmZf`m5UNGK$txdK0D1=xA4n+7fU>w4NTmM zufV{IP~ibs=n<*ED-ODgAr2sgc;d>Bkx+0x;VK#y7GA!c558D^W5fZA*yxl5fQBt$ z(@1FICU#^Bm}o+11OP1(!g2fB%Tc4N=vuKXpbB&H^nM(N$=GZ|_Bk)%k385pDC8In z??h+gmCLm_h)rCH9pvC|1|csAi848)QaoDsanOs`LfY7tUG1uGSfLWUF<&(5*X6s( z(A3HB(8);G$>^K)DD#aNx{U!%(E;qUp=t4utM zB23ft9$GVS$416ZqS#6dnlblmH0GY)rL z)izq$A;aQ#RZdqsEj`L^dw4C=}rkvUhRW zIgfPb!E?JrOPIufv85n17zwMvX0PiLPwc0l-GlgOHeL6LdJ*CMt%NAjVW(Eulw~`T zx92N~q&^PXx$lzGkT}Ibtc68QAX_AHV-uv{_gTOIRM{5FbsRusvyjDa2t{_gh^$6< zV~KM^VI8<}BjrZbGnZ>)_!NrJ0YK!y)*`b^B?%+R!q7K!5u6bo$RdPG1o=#`#BCge z@u?Rn%@@h@7fCl41$!4|B$q`>m*rnCTRJA+jEY*d+M&`!6*-6oTOke@;)7OL1EOKo zlGvfuxO`a5ib_lYX}fP2sTFQvtbzH^N}!Cd@lgl|8H2%!IH3N%#lexC8aynsNz~Fn zl=T{Tq8RFl?)z=(v$F)mm4c0s*UJw=0Up_J??07Gwxp8vM%{m=Q7ijCLsI7fi()b} z%hN7Gif6Fqo=lY=kne7^`gJy*%#CLGrrjN>utj^+drQ8EiUUToKKtt+K} zi==rSsk-qY=WQ;8fcoL4YR0Rn_s_^p<5JVLv&jgyVhW})U;9#dlI)6pmz&I0`vmm9CcjPNO>wH+94pkW@eR4C+MX`L8!c3)I9KofZuof$3cak^$2E>bCM0OB9`ac% za@)hN9gj9f1A-eBNki$xoNy@m{7&);N&TGLfHv$4D}Pwx`$$MC>fhGk)<})gS2tH5 zvtvt(?_yV=#!}=RSf^s(ZZpGv>oywmCJ=OuT zUt|ttDG$#GdidF8REk2+J3@-1+VG`JPPl(r57p12vTl;0^N6mGPBYJBspEi=eNSUd zIs2cdc>j=lQP>$N$67i&CdXc}%sI&30JN5WJs=q+&oiPmF3&fv%QejR$J<&_V5>7q zQDkp+Tv6<3nF}j+3A9lbCzguFD@&1RPbkYg(BmGHdF*5J;q9xg=no3)a}ytwxK_9) zl!ORuRo}mpic$Tjr2R`(T}6*)@^u3;qVvH5=53wU8s%F?&qbVK7{3#3KB02rs5i~e z8%>JjPvK=*4BvjHRDUuqt+M;gU4O&?Gi@27&@zsJs6DlEX7&7IIVE!XCrlzyZ7Swd z{o5(0xIa}{Y^X~K8QL(^Q{KY*r%SOY-q&n@|B|zr>-atPjpq-b`foa!!~Z>qr`zFs zm=NgvTZ91J`BLN?CIP*ukJ65Mai4w9brUrIn^@)u7)(v}p3X-KRz6%`WtlY>zl^3N zpmB=7&NZ@R3K)?9zi7nZH@C0uSsLf(PbNZyYA-{#A_7-GZWoV~SVTj2-W;it%Mh-x|?pBr^dZU1ADltK_NhQpQkXP*vFtFk^b&-d&w_lQKPdk zdJL9_4fqPi4b1T2tW3X#44FNd(4O^V%73wLW+AiwIe+rbQ+v|y6t4=TIE=*`rsjr& zw1JsB?Y&a3V{`{kvb6jE0zbXHqZqN()Rzgxb4g(jNKu2<(oA{RC#w#gTJM z@cOx&1ri>)KQA)od19ob^5wLauW!xPBr?*Jj;59XY^_iTv4&94aX9vw{AB-fMwT|B zx*?vB?7^wRK}0rDQ^N?{=50g0Wotu%isctQse$?+pYs77b8LJYqdsStro1kjO2Uvg z6Ib^6ppoEs;wU~#pSM&~!PHzONxARt(}6h2+Vl`_F~)GRyHEUsfCoN%ke5(j@>k-sl^tIYh7X7}z35a)N2rHH?G{PX!kI0u26ZTcgXt8j9% z*Zji3$S_afhK$rjPk5wF79Ed~-23{m$nxW?{{*Ib1!M7JHaTonMv7MTR$kXUj=)2->Qj44meub<`t4ZmzP!nz3lz) zLIx>8R*fP+hwJD#KqP6pk%6e$_9Xiq{V#QOQx=C0OI?4Nd?mP?Y6Hb${CG^i-D#L^ z=cFhL{b-8+{;V+eHO&uoS;j1+)!7>lyV%t8J8x=)O-4p2(h0wADZ=p`DFWQ-mG`Q` znYg3VXY?VGHMW0*2R$_BK_@Uz9u|z{isfEZPIha(Sk%ywnc?(e}8g?B2?#Zf0V=F`@B(y*kHPYX42A6 z{7UD39Dir#21>)(cm8w4WEaNg!0`LkC#tFV?%i$HAX^uZ`h9!5D2OzBxAK=mikSu( zI`+r_e3350^O?SPu0NvEh4o&jC-kX@a>nKt>Ah;nhY_bFdHl@%dKQ~7XuxZonDhzt z&@0BpsyD#*00$Neo*rgmItVywToc-=8*%AAN+X~(l(?+Jy7Ss(+;3VJC-H?E8CviC z5Ptf&rFM*~`Z$EE$w=P*LQoK7oi{$9_rYLh;`b%QK-$_en6{wW_?(B=bO+4*(h`sRK(CS&w8~Bf~D6Rjb zll=xiKq>J;vvW**gW7aQaZ|~wd})rudd=2JTa9bNJp$O`Iq?`Sg9R#?`n<4`ewttwgxotfeq3K_W|Ek!9i9eQx_N{z5 zuFIU=ect0dccf_FywGI8LUJ)eK=06g*|;wubA^MooORNFIS4VhT6(s3X0TYW$KmL- znhUobHlf>1I(1nS7rPjVGlX4>I{z6TwwugtJ}I>SxveAS{A=pwq&iMy*T~IT{2M;` zn0ldMfxXmzk!0blUB-1kM(NN%CiQUGPjoq3?0j9t?P8?G^`y+==dQ)VKS1Iizu?&CIl5PH z@VR;EbL+B7D_9o^u|b9g-QxhXY2OPKE*%n7!wb9bFBN!!~NVYSmN+fQ2zIKN{ zR?Q%cZU<#a5EivVI@@1(x4yQ_en2D6&LU2JCGi^C@tV7XQ}PXm>Kjh24m=lQWlcqU zT%I_!w}fD22UTZ>z+gw_XD=>H>ls%&lBHc-UP7EjQbJ2YlDktot+BDk_x~sMW~b_W0w+nmkLYQd+sh($*zw!QXjp$)Pkkdvbxlx zyFM3nX;yY=b#`eVOKAhUzd)tGFm`_>@BYrxt;5}|E7`4QBdzD%txsMjG|^#P)FG19 zZBp57x-4ya+--IxZARW>4($0s(__ilW6j-Tqbp-$(_`x*V;kLL=iTF!+~bhd<6PO} zvMl3r-1GBF<|lctE3ns-rq`XZ*NeN?TUR#US=z{3RzJGeFS*ySs2A^_)f-$X8`9Yu za@-pV>8rFKYp9Z*q_PLpRL-TBj2AZ*`KE?mzOM;ZzETj)qiWDizoU^2K!4p`!UOM z7@Buw$8y6RZ<8kaDs=~vYzC@a-c>~pRC^E9ClAzS4K!2^G%mkuJRWGede=lg*bE$O zrx|Qz9PHp8?A#tGw;8N(kuUci?28`k=YBU3JUB2hIJhj|KPx}7Ek6hx8YLeZyOJM= z4vni0P3S6&X(>#ZDmZA#cV-RER4R0JDs&eO&CV*!H7YDD58)QJhyIceFG7cxfWym- zipyGxE8L1}y2EROQWM@on_0tKE(+VhL)**4JI9J!+lu>FiaX>Zhm0dfP^Dv8N(B@^%A3)A7t=;5p2;pusf*P9?y}2;h3;?lbH?S}gD(mVif@z)Tq= zg?&}zNtlf#x=j+hu+U#v*a{YYf+ZmsC4D%0|Jf)R&*%fGQSy(Y5A{YVY)2pYj8evo zQe}@)SB*aI8m0L)O1m;jcQQ&(Fot+I_T<^vQ=TyfsWHZnW6$))m~6+M`;0NijJ=Q= zbN?^&K>)htJy@s!4*^L4>ip~xRWK3>HjFzH0IExPbb6!X|6+pPZ``v%3zeTi}Srbm0r-{=K zm>ZZ{XH+TD5Q%KJj1=;?SgcEmi+ND+W#U3E)g^M4#b9=jr<2Q)j`MSc-TR<-*j;9o zapuA=L&|^ZO;#)4GR7Lec-3_8!RSP1eT{|XMQu~0VM(l}auFyI6w_N{{mWsn>+wHt z{+GG$pKN8#T-BX62M^Zak6v2Mu_TrzZ`{KOFQ1-%V@=>%11}bz9Qh}V#lQT4r~s7T zjr-O+aW{}_g7UeF{XUNdmU*@4UXQZ!$92`0gM&42fu`xZitLxKvxB`D;oJi*zsimm z){;x+eSKReI$WOj81k}oT8BKG zi02NH3)(cgZ12RsLMP>5lcgM)%u=_KpoERikd*6Ho%--K)p3=9`nAM;2I>etCi@@# zM!xb+vddtF2cAHKGy~9>Dw^cHELs^JVZ@Xn)J(4O)Py1n$iS^YHu@v{19cXe4O4)xk}s(n$oyLu!8+OhHjnJZL;oU$9P#IN6r!HwrJbV3-gIHYcON_t24+aU zxpU_~#L)l!Z!QK%7JOYs1UZ@A$1NPn}7({wN9=w!< zbia*LzdcZQEXOP&nKe?itSKNw;z2~Tq8mRH=w%`h_rJBB0h2_dJW^fcsSxl4-l?0e zOui6o0zdack>)+=KVoXr4rHH>nDi3)_^dY%tQ9s!qTjUDPB3&Tjit(qcT+_%<J{(bOHcmg-A?Qe3UY8Iv8}#KRFFgvJKXMEYYgBdl<@kxwoy=;t>gIrbdUf zGRa}CgG|ma>P|yf+~Sm^|Ggc8+uLIyy8{ALgC+huB=>HI1cPn^=+3)qj_WDJa>3}O zz1PDtIs+eYeVcVC%j^oH7V!o4MP&6xGN@!|IhJSl#l6(-_U?6QW8F6cIy9TU2N4ec0r39GlcQoFzbd}@Ea~8O%R5- zTQW)$9YrYJw;ZJ^N4OHLrWUXgt8SN{9jEErw-TotNVpoW9~-cmXi%M>lVsH0x0+zBHyN;&s-APdCn6{!qB~p^WMr7`e>Z_SJ&?D5J=0H+`bAcdqOnnSyj_of% zjodgV>X&&*p~lAfd4Y)q(w_i{)8Fm8@ddd>j+6asMYX@>wuQ`=|sD}NF z%aX=}@^^bp|5k-cn@@}riwnPNnXp#feI&o%HqIoBX$Nry@3)ik(HwM;smmX9!3~7V zx+$!K54sWdv!;)wIeXcgFLP%1`k5~Dxjr6Kkx{6c(D+(h^-D{A$97WN>-uE!``2V&FKr;Ka>Tf1 z=-;0=4YYi-CL?C{a}EP3d~;5x_@UDU`%7AW94*yP(jvX*Z2uN@x#;Yc!o}F?79&(d z>y}~-LeE!YtX*%(C^xoxecuO?-GQH=B^UFB#KRX`B|VC@w?Trr^G16h34}SxgoyP+yTW=m(x`Pqv#t&XTrBe14WX(gJgN?0+%THG^G`d%cIa&SWdesf@RO33&o{g?Q5+Bf&n%9GDf>i|z_Dz{PkN?=P?aB+gM*|LJ%_ zziAG4n!u3Zhoi)M-x$@&82tRv#V-veI*`5rej&;45%J|646S8wbHvg^540=|lU0yO zcn56<*6ZbaBYgjV$f!%G;Y;wQk_SaJ8X%P?{`|z3u`S~DH^XrKUd!aqjMCh|^M?$r z2VrjOnVE+i-3-!qLc{D-=?^gk-0GhcV!w@NPX@n`H~2l4*gTQL+4Vx94;UfsB>tJ8 zE|8V%=>7{uxd%tJ-K6`ikZ+wGnRql<0D0g4CD4;@m_JYLLH#cur;q8V9b=6;xyjPN zlOny7IconnS~P{Euq&jM3B;+c;2@32C5>ge5r+ZJ9s)j6c8d+ZCVX=Bkpyqqt?(#G zrF*xsEc|DYuC1JAj~Z`zT#6}O$D*2NydL5y732w9)v#Ob5_N_9J%Il{L>z#E1Q<%c zT`D84z~?+Ht{&Raunqb%cTru_+Ei@mD5O;oom=roummZ~Mf!C&5T1wtt4`?zwD8`c zqP><8j{XZC`ZigPyDo8PdN)6q$k(_cwddH=@Z~a@pzaQm_?f`nPZl#CWYYvP2(`;V zx1vl64~KhJ;Hy82ptx2r8;2)s5Jw=S(MFm|>s*afJDV8eWP!T39%F+XXYT@x5Pky8 zqw{2ir=8nUR!%B|iw_&7J6U}Xf(UC#%W7?j&GPpo$zCsfRFI(m`RkrYtw$g5ab4Se z>B9*6#$`q>%|2z<^5_)#hkoYI&CFAjXn3M#eMCG<0Z0~JRhA{rsPn-=k>ei&+1=fxbo?ucd&TL^dK4^iz-u(c0H5OpgWoRYh&m?-&0sGU{^ilFt33 zQ*3mA2`$6FmFC62^wSO%i7WD(?~??Fb18TV)QdOO;yxu9bOuFib1DWwmu+wt*5Tl} z^)GLJk4FBp#kjGWq8>L)_cGZ9YZ#>x3a*D-KJkBjmm`7InQHKx;z^-)@s4?g(;wVF z-kKKHUCStE9mV9p!f);XZ0YGs- zp6K>~jdHJNgLibrNA6L1kUF#w-t47ZHm)J39I=jU#~E8ghHuAcX!Er>B;qqO#s94f z``aqf^>ckr}3 z=*&GJ^QZKo>t&9M*_zu~oX1t18%>@EWvvI@v@Mfca)xr@!s;3K1$3s zq7YRJckWzXa1<7bN5Wk(pDNHKTO7n5mR|a|gioHAL7uNsua{Y^Z<~S3?+>~lUoh9{ z`)`JXCR4y6cfVs4)Rn{M1H{Ku!{6J%U%}tsE8f3gLU|vrN6L|>8HTjJJ&dbHfeLvD zF9|*mBDCrL`J#J$GuC}GA$~cOfm!i^tUPvM@szr)q#ad+?m2eGQ@{>{Z!ib2($HeO z)j#Mg$ag)c4H6v4AKa}G%=z82*n!bx#hy!#5XlR)YlUdXn}uV zu0|k^KXg$ebipw6uZF;P45+9!c=s%HpE69ElQ0?l@c;*n+9x_Wdtdo13^e+~8Uq5n z2shyX-#COr62ghe=@?RlhECm*5e;Y~2o>03KT4D^TI?LeR2wPn7%iI+ zEms#UKNGEZ9<59j^Fbg+RWnA-F-9XH=2KmaR$VZ`+weOA;eeSKUCmg1$5_LJSmU}_ z)0tTF^H@u&xE}&>Hkxt!Qqgayq8$jM?PlU!&*R*w;ynf8y*1-~9pn8I;sfjADPF{W z-G~SE#YYMxL~AC*Iwr&?BqY`)B+n$I=0`hGB|0X=rD-PSI40&LBo@>q7R@AZ)Vv%8S&^y43BN)ZO#ceX2AfD$@UD zs{dcVAoxH10>9B>xMPJSw1W@+STq=7l_3>C`AR>l_aLJ?jF!&{O86t|Z3OeX9LB!G zto}Gw_4;5!>l}pyev{R#zN4JsG!gp?D3MLx`*hhLrf2=fd1JW>ae^U4wgn&aH462! z`%elcOSNm9V8nJsnq`IqInM_E75%O;o2m~XwlC4HbJ$wV9yl$TZ~1w80lVVRq4x5> z^$U&qLkT=aG;d;Q&aGDZVqcgH4^maE4WRklC}``gH%4>b7Ag)=S8a`B)LUX{8*F!G zs*SdWhaT7L{b{wwQ_wZqAN=hOc%?KG&ajFfaNsI2qoWh8%})0n8J4vA!>Ysg z5dPh~?_o$eN}0z{iWk_&AhJB&NO}15vWGI;#&Yy~(HDBRO^W>N7%lyoXR#>rjg@#E z*BAP?O^W=S1k;=urbNq{d- z7lNcW^RhzJh&Hm~Kgn%mC+gq&g(Tn2+a^UUk#UYI(`0-~(dx|0RLm}ZW3#BN`K3v5 z<$OV5apUUmS0$~xn_HN+o0qpW2fD(dvO%WXCIyyl%hWTUNU}#FE7jPzdNy=(r+UHo zm3a*=R>-_|In~6xemi4pw|=kSl|{q;q>x49vFN9r@G>w3mv1%t5(z5D&q-1)~P5I~#s0uo)W6{jGWOCU1d?Dzt|7DF9=Ky;{@{d98 z5mT-~zJcJQVZl?aiYBSULaQE05;L1_S!i+JJbZ%t@}SxF zuRncBEiQnox%O|U4E%gKMCHa|CH`xPfWFQn8|ek~Qq$>r`Vp(sMpjx${YFvlyx=CL zboXMrtcA__PoN%K-EQn&v*Ul86nh<)<}Ul4&)r-Odf7{Fn-t$&DZkaI-~JO-eohnl zS7ovH^Au-7FM7K8pC-jZQreA*0p+pq(S8ei%h9onSj+WEPa6LEVrkC}4_HnEoXWJW zlNur5pVIF3)F4k@?|Z<55f=!4aY9~BFX*4*f3shVu8m4}bDsm~DK=5{wb9CGbHWPafqBK>U4k?=PB6q_-2wHX5L? zjUdlu?s_RY8u-33gQ7yVX5$W<;62TdN0eD9968-2ZKM#ADUA-gT}+6nOD4^nY!CnB z{ZNPGOxn}Wqh9o6x6Xug@ssN8H>SGU zKE-dNQHi&H;mun)#n7?nY@nXc#5(P%qdqZoQ;|}M>z%;Rll#-R>bj(iSYiDM++yF0 z$;k?4J^1FaXS_V%qE|+Q^C)3Bc$GGLX3*$UMdGygDo<6y&>0Znp&~x`updh*p_Z)# z8tstD(+iLwSfs{t^axJACiw|OKFDnC7F)cJCvn{DpvqOvM&6Uds#1{0Kxp&CA0^&; ziOO^+?L&@^Of-9Vnc!UyKXD`%so45k(&MupQVb5P?9M=UHvr5h{TIvyc>=&qGx5~D z9Z&du+oaghS2U>~Pue`r6(%TXn{;O_RvkdtpaZ|Ld^UtFBg!S?V8*W*WmO0A-&0Oj|333$Vk2{3Wt*x4N$+|*D#C%DpS3|kIlP|8^PVJ6 zid0n&(hSBI@m+b*&Hd^TTf}|27(VTMCSB(8Km3B%v=SVsq zFr+(5_?4{xLZBPZA|n#y`l6ipiEDok`?X9|`qlEYQ^yh4)e7`I1w-WHi;*lzGT)F& zf^%KU6xgRpv76Bqx9GYdlVrnSPK`e11u?TR{FTgOvJGY%H z9Q@Pp6T|PxTDIe2+mx+OM;BAgld4(c9llRD3^K#Ns68ITtgvmpnMn2J&Ce7uHu~J~ zdpf7OI(Nv-Q=sZ)9`6UL-EYC3jdyf2jJ@b}ufx9nbDC=Nt!Z3Put1IcnLi1&Z?>On z)*JgZeOC~H0%!Pnirl4%D&gj*v)VD^Yy30gTid~KvmdB(wfJD9)(lBO!K-1DPd9e& z3Br*`$RhCZ0ba|`?D{SI=hEd0TiAJ%Uh`gJhs!E|&H1VFCBH{<`&Yuf672J0tDr}; zOJqv*qcNe!*+NYl$~{lA6iTX*5O31lK;!$-s6WM9Z|DU_&WAb_|CJ@BtQtHL`aM-* zCrh;)r21~L0`mCy?Uom{l34wGLi1_k(e<7yiOa+tF^A{Ra=Jywy^!T6pX`LZNKM(D zm&e)8tJQA~qerfm`P?kJ$eUq9|8^^gl`fj&QulL|L^jpg9e&lMp4`oyADS6*9Gv=c z6x-9ZW9@c1e{^$7xO4geOmBl|xH&FW50@uwS1vv|R6pHDAq$;ZIMKFM>Qy>z8!?I9%O}TzusI zm(P7(-F?a4{jQ-qp}#xoz5)3u$Vr(P&jFj0g5i~k@3@n?O1aS(y3ta)(QCLN);$>c zJ(;HI&eonwdPy?dwcG1F z=LLgLbsA2HLOy;jLMr_O!hAy#B-?vmVok={%Dr3^}%l+1`GTRICjr3%gxF<;e3! z(`9^KvIbUejAhBE35Li$EclhLo^4zlShY1-q}AdCdq+_;Q)bXrz#3G&^Sjb?p+4l@ zqmsEg`<2b&pqjn;Mi)G!*5n;H4)%WheCqkiDwVW#hQC#olO*O3zhU3MZ z;n#KC8{?TOL0`;1*_uq|=`@61*YocDsjwcHH~VaFF<&v5~fdz}N zZf-Ycch)}vENJv`IzfavC=~SWD*7u-;tc2cKw384oAOIux(|)ox{fcc=h zf9BM@i~yF{^>2Z!?PrUDT-~yn!Tj@inISH;>kQ&}0xER)4a`V4LY63xG2$KF%&lUU z-pGnpRy5L!(Gbml7W4Vbj9#pk??!e!D%40n!O%6IDZwOlMnBQCdxI&;?8*TZ;;`EL zJyqziE;rQ`M6{0nNp@k7?oU^chYsMZ&r1)LzSYSgA1(~D6c1&~F$CP_ZuQApH|3@qrmH%Jty>(F3kH5FQz_Po* z0!w!*Am!4nl$4U9G*U{6AV^AgBOu+~-6cpZy@a%~bc%FI-0Syy&M%((oIlPp&tK1+ z8HO2#VTNIM_`KtKUGHJWaOJ}hLbWvhQKI8A$1!m*{t5Ci#n0n$8Jcu~2|509r^%-Z z_<~iBCTu?p>x`ud4(qv$oX%)_&73X1^vZx0L!C`l_0i`T9e@x&xSP{H3FCQq~{fv>#6$tZX0RAx9ACb zG>`js(HQNH^jr2HBC8es`yzX_U+h}<>NdU56ZT&ff5(o0buSqfN;||yDY1I>ewNHH zq(?!U2%!YjWTYRRN1UzBG@vMSS5u@6PhCOC6-0U^r!hVPKFC5PebpcW*wGuO#I(<_1M#V;VCTy}H=X3eH52bqR?s=6l z#CfKlauw{DVxAH8m1vfi7-!?oekBm93Xm)_AsN!?F;y!CN{+GKmklm&*+QorcC% z&!lhT~ zdD|D2%6Bt1MrfL;eJilig90bhML8iVjtSBsZ~cAi%{e-D40py}T*9Df>V$ycT#?B$ z;@@JpY|Rw|81Cy|>Rq%`M!gH#99qV+x#*_*Q5AfjOci0o_XxYV*VNY6;MD`+F5pXe z1R>Wdb^k@5K;L16i>;a2TMBkyJDyDG3rev7${rTgnvlc@b5@4S0cE?Ic=2al+pb-2 z&T*vRK3cC|ld<+2Z&XKlaBaZR!*w&&+T?G;W+48+yR@L@7=A&r%?AopgQ^|18OOsG z;^$OjUIYRF*+CHk9z0$fRi|W%$Tv@fQ^sw*>hf4rtSIQ$tVzhHV(!{3q+O_yS+{jX zqAJ#EiDFa29E#~_Z{`dG6dqIk zj|6KXNA_Ne>(2?@gOo z!q)ExUgre87Lq61&{3-TOwhSErD-rm~uJm5J@sJ&k2jK1u7z5hNX8d5fr za%hOZ^6gV_+M3i|GtyJ_Bo^GhrI4tpHh}*v4$Md&RNXyXXyjPZy|^PZCN|w@^g-OB z{+mU4nP;$_Qw`hA-WxUXiEl<{^(^1FbzgJH*8eyyok{;|xPCtRpi-{tmF7*KVeK_L-?1==&O!nl0~;QHQF(Ny4hZ!#m61G z%!_ZW9j8^=o_jPxSMv+EXHAWs>W*W-fBe2Z?fHWlM%gf0rJB5fzoDF({Gmj+*3#Z_9 zJ>K~~)L6On#Rm(I{@I_B=+FFcxT$)zKZfOL#8~Z?~f5EM>Q{ z2{go6)s4Gbik=_p@6`+nrS8=tNDSmCMGfsj2jwl{j7e%V0+;kOutzW zzTdLbFtp#g--q|B?P&Th-XQ!}$JN=;uP?VC=t1XSya9*}jT+zz4`zG)@n?QGd^E(<4?P~{n|XFTBD9H~AJXrRD=_#hcKv8* zRU%c8wO#x6hQVvAx?*xu=I6IQX`oEvVESXc{}%vi1PEs9#3VlO`T7(EAhVPLG28pX(BQxy6N~17AHgsRC}=57 zU~2ErxM%#Ai3QCY&_7#-ys(rZ&#@1L+nNBvg;{e9?-97n#=54leWCyQ3(Xra!|sCF zdzgt2|2DA{^|10ChN?y||203eYQ_@zBYJ(Za_N@y5f=lY;KWO!AR z*x9rGFp%n*5%F+vMg7~FwK)3k`YT>8}^_a-ZlO83C{ct_2b5KYm>H zvOLq^8+z!?Is5g^X_fC2E)z1F*&Z?H>gcyz=4_mEgM4R2M!k4CgqWHSKgQ-c<8vD- zaL!M}p4Anoa9eAa&QI68ESMz4QX_glMt7%L?(E43A$BbSk1^YmwKoOQ2UyP`jHa6q z`FjqZQx{kG&YMRg_uuC)EN<&Hw#)`rY}O&}M-$|UFU`q*-G>3ZZkU)nC=r9e_hHKk zQ}qX)Z$EBwE?rHWcbttp^g1tH`nK{p1LCko@1-o_d!22&W{4p2k{9*$@wfHcdIF&L z1$SBtw@$TJ1@R9r@7m3F)Ahka6u4H1y=Qxv9#uQ@i!b>0Fr|9Ln0JBvfK;_ZgoMqq z-5||M6-bX&R=(~eJ#VNIoS8`FsWv0+f+; zBR2xWWK;c$vpvrOWQI8DUie33Dds(p+;&K9omiI_Zl3YncWO=jzGElcwuCRe|1ys8 z_irp3%9j!ma&E@0>WJ2thpUd*qHh)wLPTRD_k#GZmkFXpdf0dl!=AUrc>$HgVU5w2QyC?>*CTk(6zbmpdqZgKvdx9(SH^1E=415B zA4qVL4L+Fw;!HA72L(i^NAw>s2S@?wcF(^K6NW-M#a6~)F6#b76pc-2zYKbQ*uH1^ zxOINg=;7le1*j4Q;d&11U~mOK#o)`SB$Y`ZATBrt`Xu(N61&V$N3C58`|Di(@X4$ zkoSSybd;vvmREOj0F7#7CFioAdSY32aLjzG_t}B>m+MuvyUT?yw=!Ph(mi6vBZu_N z75*+d{CJq31eaLlAr79nik{FUPl6CnSd$MSyD!nK51EK>wk{?%-AJ42G{KAhS(P6pT*8~%ivx?1p4olncuxsEc5;8G-{_%CZ z4~1(0X;J`rQ2^y^fMQdC`c}ZRivZ0nsZ)Q75pqCEff^YWgkao%z5nfAj*I5=4^#er ze3bsyNkO(vL3Xo2_7_2HFJx0+=$o3l#>u*TlNSg2LK{#5n1{ijEf=eJ9}UI85Rt$z z*N||Xz^J>VkO<1q=-H6yP3I{mf!~d?AQC_Z$cND%3dRV*$imq-br&+Th7dvS$b-w+ zgUc_%Dk#INM8az>`1Inn%@R}$nncnYgTI(Tfxcjzers6~ElzVBT;7kdkkGiMi1>?$ zQOd{>k;q}4NTgz9yOWhJJFFg?{&xc0y-5G+gwGf7+TF*!75;Fyksp->!_yA0ql`Wf zi9XVaK5>mcRP=XUHcVnSUbPf=O_JsY3xO$RpuUk4mXSDak+`8T1amR4ff(ZE7*aPO zZFYTJ4ba>@5c6v}O$vb4IZuHrKhz|eH8h&t?a$~g;7Njc-gT}yu5VAzzD z3HRJ*p6$=Zm!D0k63mldhRLb$Q5mCsh`$STdg|qalHz4W6BUZ%ogO59a7%PfPW%{} z$mx`zW2qK>kM%k*=(SstS#nZHb5ht`Qbh41uY*^n8!+>}H_?^>?#aoX&52$EXx<<> z9VaETIXOpEurF7a2t)HS;8oCeQpIIbB~@ybXljja>M?Wj-3;vVWn4IAVlGum9#vYW zXxbOuwEPEYeW7V_E-92aM219w_f@I34_+}S!Ib1vXRT7_lhYTQ(|_PZ_maGExjO)n z5v5-U7%Njk?44naDDX#@v>u$y0jkVF-OLlW%;V(DBdTW>1NU7}h9o8#o6C=LURwvi zaXXV>gIPGCSz$|;S>LJtm{^GQvOD@f;QYA2tMqu}vCo}e|Wdo?y-p2!%}Oy{UT zdxG(k6d|rOp_4mo8CLiLv(TfYQ1__tBW{rwby0v=QP6y02uD$Aig zE5x!{#^9my6K&fpc_X>Wf=I=1%X`5O7IK~|Yi;*h9t_e==@dQZ%vsi}-y!|9rP{=Z zhiT*%YFXNTpH6jsi`0-QUh$kAzmacI6G78&8aVrzt0yJwF-=~+KWLd{zpFn`V%BbMt>57{G+k|6D0U&V-H!9O8fu1vHw2!Vkf2Izi8}N z2h*P>u2PHHY%GqnYCkmk^6~DBj;qNY)$KzaAwVUNvTLw>s}FR5?!ewd*&&U25|_r{`&NJy`9GBT?uu zXm;2f&J@n%X?H)_Mb`)Z`%sHIty$o^Jtl++pJy=b{x_O(w>2&~Lw6?wMv zV%3GH?#F38da(Za1$yC;pkue4pJ41v#hPgD^-9Utwr*2{;Zu8vw2%L^~U3E{j6Onde`xubH8!b-3AQ?6?oV+Z&l!7QyJGe1ew~E zIHCSSaJn^AiB_!A9DKpVvbFDmkdz&CLK)hsy7Afm1PNqq9eOFBmmT)dzG|!Pr?a3v z>Sy-1eLuh+S9XNp%519{;x42;9^&h^bsQ0yEIS^SSZb>sliZ{|8I!?!>ol%NQhtJb z#?W3jslrBgI;ko9_QN-w=jEqU2Cv%dXACXq&Sp&g|Li*A%FkwPGus;$-WJjc4_M@3 z7`NZYAO};L7u_xFFYST$8&Uh@1Q)0f8dbDnl?~m~hb!zitwn01>7XQiRkUK&0^QT6 z2Jbg-r6&?xZ55@eq7|!!=$?LEd0d{_XW8(R9PV~v(4zf0I!N#i>+$(P2l$=D;a7@i zv|?2_^ZIyL_RICjsKPtR(+Tb9o6~uh%$xH?|1UQeKZ4&qLI(+=Z!dQmGp{Mnm+izA zydpI=cP?5pymzm=EAH-Y7BcTJA)Of1>OK%IV@w=GCl<$nFTOz*77L^cM^D|4vdkFt z0MUi_=m6g%IaB_OA9P;*(32g#>zL`6A!&dIJ~YV2vw+A_4^{<F%vb}8vO;{Hq7Cv`*l?7Tm1|;? z+VWUg29%!WsHvWgugY#9gG7*r@y>GVw9f|yU$oRjRchVmvUo70*ASb~SofOCaRA-e z6DNL|x({C!2@*g(iN|kNW8j}tdgFfd2Ml7(7FDqlLhm}-3WSQ$yN>tA=*Hecs7qAU z!MQd~A%MX%rWp1Ga-7~y%PQedHRd*0n{km>_~=*jotnoLf95rG>+x?Kq%Uq=7UT&j zee{5;|5RbX#=T8ZHjeQy`IDTjJa&1(6pe6ar(F8=B1Ktc&6t#vTz0zcCz(y7LBwl3 zW{O+VILc@+XevwOv(>+VL2CZWQ`yw@g^JwlkZsh2?@1vZ+Y2k@r()lVG+&hHw3oar zAN*G0E?8=ay{p|QHeD6_qHOKhpJ6^I@V9^JWRXe$7!Ye z&r)NI-I-2e=c>!HUao0uP`g9`lIBS~R+Wt1oaY&!6F{zg)iZ{ZdZH4;FY#(1AZ{)k- zZZmcC=EGi}Tt*jWUxr0iAXVY`u+#v-VM4mb4v}0TB5!YF(yxb9idP(l$;HnQgc|n> zcIt-@|751jMWC0Cts6p1iFYIKoNH57pKXdRP>s4I@MoN-J(j<|=nrM5prTZU*(Uju zVO9rFh55uNl=(CMpu_Vzx-U?4#5qI@>SqxU!eMc2B7flsmS}Q-LoK8UySA{IoYTzc z8?U~VMb$@H{Iu4cNtv$dt1ox-+%+0QlHc6?;<#UlPZ^(LsWX4(uPld}-QH?+K*5BZ(qzK>$Oq2M9sLcsL-*jPAr9T#Ltt;|}zp zAFh%kV#W&;@ws37^e(kKD4MC(!gYq$QIT3=!-OF6TMk9N`Z6`dArcU?O5Iuoq@RF; zBy~(F(E2YS%@6?zKR*2WAPb@)9 z0vh3OwyNyzbZ}W z3?rq~R0+Mi%-+Oa4!SSo>*fc?LV>}OU!SPC`z1a?2MN$%&|~*MU{L1HLQa|kzC{lc z{#aHYKY$D6TZcij>ncYS565}t|FuOK)gySje9WQ#5+}3@TW8BLsG!%kdw!`n_Gl{+8=Tg1f7+$G0b6zT6y-d0+qbzT?FL z`N4fst8hxI@OIAi>yZy@k*~UZd`i`Q0^mMMxvIcJ5J^8aW;QUv*u37y;0YY3*vAcp z1dRk@*P`DyLxd_xP!s)0{2abbgeDGhOJf=_t z^>ExcI8Ig!+JFVy_c4xH2Tr)WcZe0}LK>FB1nN6Yx~urzvv5-2j=RPo2|n<4BwpTK zjEq-|6CrQNU7>fvtT!MjG*KrssVOvNHZ%(nnsX8Q?4EPinMJ>kPbvVO>f`h1%;L)v z{AgnV9b;!qU*CBo5W5;Jk;5rKg{S#gT79BG$l(9@3~7yppi!v-V!P-SumvmRauVca z?66-2+eJaW)B~T-MlN1NYC@v4D5KUyqBgc7*F&N}{gAqIvuJycxC4tZ7-VR|1JfjE z)*e;_z|DfAKX6)-$WIOhXviH2yFH2d!4EA2_$>$sWN{*OJbB_ley|V_5n(N3q@j;# z?p7#0RahQnEW_Lci^+W+vitv zpG!#q_~&Mo_7C`*?J)dG~ zC@g^A47bVhii69U!HrojlODLma^fV%Q6mB7?>Gq<74u3 z9HpEFk6e6(**oXh-l42Q6SK-Bm%_q_YqlZ6Z-)K*w4tyoS68P$^N=v1NG|mhw!1OR zwF=aZTlQrh-Po5sUc2kKIC>!Nh94eP<0(RY_#W5ERM;Z%e=siMKB1s2SshHl;p2fk;@vB4R49TPo3V)tjpZ zTf9aa@kTqE##1&377f&|>Y*PXrI4~vy%uy~0!th;2x>J?aZfc{Rvq=Tg3rEwl>n(% z_x~=KG42QUg@Mfu{2QyJEo@+@m|9_*S^@Ev0{xaE&X(e*EhP&rZ=C{fPNAj{_FQDt z47|0Q5B;ctZ4`sE7(d3h7=I1VNWsN*l_EbQD$vL^p_^`FAlHk7OF$HH->1NwpEkbN zZ=CgLpI>O7``x~Hhu5*dsnWn`>k8n-0N761@3so1b*x+4e!KC>x8y-HXlLzIw5(nkbV?&7_)q1Tl2yJWYn$O zErp-;8g0#l=J!gZf5S5aD8^dCCQMM@U5-=AB(79RZ za55sq7EyLRSV21!wm4V|9jd?9Mh8-~;b)Bh)0^6wL!S~F(HpBWFgP+cZj znys~28f|Yq|Ap#Ct1O_1%2!eZ8eqouA-ebH`^KfLG&SL!llYa*LPAv(5-!n{YpN29 z#=t&T2x}3_4=S{U@`uUKPW$33nPzaN%k?bY+bxp~V{o6Ui4qS59;BJD(1GY=HLJej zNRRZ$M&AdiG)4u8Y!t0VNt|u1MoWQ2VN{?2hwN9dNLD2Oe!L8lV_$+lCYCArBoBII4^ClPgL(GNJ%-B~Rfu-?T-GZPJBcZ}oA6u~%BMjMGH4)r8XH zemYd-sD$4U2TPm7h18jAVediG?n!*Or8XeWy{aB^o4slT--|72d*(T$l_peR#j{j} zGe$Sx0T^ZDmqr$1BTOv!ETJBVT9POQqEr+1S~eoMD|&}`q#K%6QzY&z;{*ARZi6GF zjSm(c3J&20KpTg9x_Nvs?wP8#?!#mEdPucx4|^#s3~VVpofbowKRHd{*pIj~*RX_< zPB<`N7b~h~V$c@YHF*uTjq^*ANKnhm&2P=mf2)tM7m&f&amK4oBzyVrNUjSlb-(`)Q?BVSFD~Z+PZrpqvX*#RDOLlEd1;G z7`deNG&V6%yB8+h-y>fGyT+DK{4wW1RYr?#h+qvVq&l8}KG`2^?YB&=0tpD-qK5F@)zsx9 zM*RITj00_^8U@|9%;-synIv2lU=~j7QeRVp`JoeHaBLK1#bJ(A(*fLdHiz-dZv`@g z=o4x=jI_6n@r1n3dX$8;W4{+N(l#S$<@t_LXaT}$t`#n$f=eHfqN%KNg1h$6`Q_2!Qf znuhav9?I5;gA@qBbE$YslV3ZVH}zhwAZTjx@axLV=m&MR`GGm2?@#AmdavMY_S zv`n}hA{WPJ@5_gj;nCM>DpD^Q$M80>(zmAu6XN7Va@|BQ?jjd?&Iiy(ey)x)0g|}n zUy)Bq+%e(UqUvWCkXis-;3NAEf@=l?B>9Yu48wIjl9(yi=)^*7E^Ub!$8^9q2J5Z1 z?22qbuDqW?Fe9Jlx^$F$^ozb#Um!*>(p)s%=jxSnQLi4kF-{QV4FO+cRG% zzX9)F2(Q5^*tN?{yB0xA@BZn&G!z)J%|}UuKau}#G?%JgahGc%tW__Gk9o5PRgX(N z>X!FB`WGpLa$Of@H~T!Hf(fmKd^-D;`Yh70X;sFcPd!C)O3rr*L8)$q_ZHcmg3;+8 z@h1DZD{RdZ2<^Q0Gx)K^v*5?H80!(_d_%7GaqlKSSplWTXVUM8%@g<}%j{f+7Jgiv z>B#df%WND^Emo+1M(#j#r}G(HfwFoc=KB`aO08Uh&Wy1%5RO4TnAkmLBDYvYeglgb z3n#^R-IF}23+xfsQAftpfPTsB-MOXt^Nx#=N-r`knH@tlNo-LKvzx8<$0k4>Orl}V zRPv{(s=;KL*bipS^L_vX@Vm^Cf+KDPVG?y`Es$+YK18;XFC5c~o({pD(oiZl$eyYs zH=n>9^89wrnX&wPWt7YCD*f+SKC`S;9F}UBEnQ=iZ}&8ZCfv&RVk~a^DS>Uv5}nm}&*9xey8yV1HPJ&JFzSx{kguP|y_$PQ-GNd!2Q1=F zRO0Gz5iYs6`|2IdECbCIGDNj5r+6Y%+*xJ>@O=23`ZxXIhc(fZw+xBqd74Oq9)hjs z^V*d{F}hi#^dts~!0qZzUlB9g?a7o<4ZYF1=>&QRImC`)WY_5T)lBD09H*4^&u;|H zxxR_kpyYv3H6MZcmuU)-*zY5`vd>bEJGZ1Lees>&5(Tv*EV8j<#oU_xsa*_17^YuG z`QZdmZVtzahYV-rT%TX{ch$(Rb ztof_U=y3*GWg&C+^an1nlQuM+NjZbi>g+p_#ohI`IvN}=%!rpagCv1Jb!+gi!8VGv zc9{m~zxAu0)ui6+F**dYRTRRc0Bp?4Y88qH{A_g#H~X$*9#iY;Ax><0cqW!TJs*g5 z+i7kOLewPYu`vHB6iY_i=x6}{u+gzs{fm3f4~Th*3)K_3KpQf(0zW4ZyJpd!`7Uqs z#>oK$`j`BNaRL-%F(nVjNq%%u=m#}E7$294X753`5fIlcy@_B4rfSk{XEVtiTpo@) zV_{>4Nj01{etXl00SIFRe(wvBD!4bHqo)l28Y&gnLy_Xh^tA^qi6b=!Gx7QIDG{aO zhv+lX2kMft`I(C#y#RScrq+VRvmk(F$@w2gTn!Q05f^LZ`j3JWy(C4U3(jw#zYESk zg28`VdHzoe&a|Z7Xa+9v|DI(QvKje0SpR>q%+5b)tf!{y{!wuLC(F#1<`kK7KeE9c zoo+6jDdO?_cb54Vuk}pS>pT)$id@0`M(2OB%qp!y+VWY_Y+pr+wJQbQef+|VJJs+n zEHeRUjKktpItOh(+UzneXjNt{7BC|W< zg*t^(8gC4JC`toK696F&mT`c{C^E7#|FhthBzIj zR#IKsVWu$3lndwA4^z!~VdM!HLBaPUbzO(l+;yx>9yWedjA5uZey~U*vr)X6@$qbX zGjqq636Yg*Tcd2&)%gOCTz6Pg$Ev+d(=x(Opl*og_lKFM+zqbUDTS>>a)X8B`zv&= zfM+Y;Sql>MQW(l(@8-k9Ia8=hi|f}QTNQ0*JG+&gpr_O9&17$EtG#?Mo-pPpKb380L*pJ38dH?_y4x;{LIWmc^sR(?CeO_&R*jfArtKyY!Q^%vGS98;Y{-Z z!0V$}jg()VSWQX6plE+#gDq5wvA~W^-MO_)21(nVL>Z+Sm7bxGm*DQH_Ec0?6>J#6d#>Cn}ZUpUQ@36v@m)-`EEH`$1(u_%sfW<#a!OZXa z=?%EILUUjK{O;%Zk`I{Y;`J*+U^0W!bH~AMU6J9URb45Kr@dnrT}Y||Rlb;lR!TWV zDY+ACo`O|+;f~=Vu-$Z-_MI#afT^HD1x81{BXP0ccE11nSEr*OgJq?}+qSi4 zD%o8bKUWL4c=8U+xj9VinntOR7VQ{MNxXRHZOrnO?-9WJ)8SIlZK-d@7D2yme;@Qe zyt`(3eJGz}KXTN!8d1snw6`lJd6_^ntPdt9TSTllKjg9)lUKvO`Q0Bz#P}YR04_l zMi6|&6#bshyp@iF+4%t@!f?lu0;=W48T@=Wm;M~l|40V>XbLVrmBm`WES)FZg?!<4 zEeP8!f6BOz+t-Y{!qe|MCSytZ>LEfHc8p_=Z=@_MU6lovgp5ZQJ9Zk>kODEIF%1k#O8W2S$IpA%h6a)Ok=#I3nI z*#lpUkU{|#BJB4Mm(cW(Us6D--|R>b`mP)-lKq)G2DS1>R+H>(prv#6PykXx)kJ~} zJ|qHDi3!t!Ykfv|Su)|7BApsmjIDZD$5ZA{3PeV%WHJB%3k_M$e!iD7!_u&t{C%J1 z)a6si*Gl2jh>S$J0&z9o4nNoTs&-!|xDpB{E8I^@jbG$5!l@%Z2}YYvv2Vywlc)g< zYMERQWEnB9KJgGqwZ(5SwC0#BQW{`?yXRc8Y@oA49!osoYfyr`m>67#Bh<8nUIb> zh0tS4gJ-YK8awg#uQC7S*{=;DLqGZd`RqrI6&UsxX~<67#4-56n26^;MC$d1lNX=j zl73=L(8J~DC==0K=7Q7BP!73{OtVWnOu#0b!q+QAi2MVgXH~hG#`j#Lfw~S_3Lz;x z9n^~Kc^ZCxLT|rf9g-?ZV?NO2C+B@2C?m>whKaH;SM$pyH4W5h$jPjmvHoy^D`Yxi z6JKYy(=@Q6=VyFnt+k$f?r?r~4J;E0c zoQ7Ea$`0kVh61O;-x9U^jr->ekS)EP3B5PL)2Tw4!HT{OILtFbNA|bI^nZ>%{l6ic z{#z#Me=|-0n`!#rOw<2nn*M*8X)^zf$fLJ&uT4c=7l?E#jXI>%rfo?SicJoTerTyp zKloTEv09vg4n1X@4YNHu|Bvg^w@`K|FbC3)INq#`RQ8CLIT-$PnPBAFUyegLnDL7~QG(m3+?4KRv?r8mqdH;1?np=EGdHUZnO=tBLk#7-p8sX7*IOU&lyEa^JH=nis zRR>g{kDsD{kLf1#6#0F0>*Cs9nI@k1g$o8dGBYh9&w4oAv!TAUER>~+0S|8&V+y+L zYFvx=q{DUs>BFW|BiVtLo4<{&(CAY}V7b@pp{1Lv#xH=|N*}CeD2&7-qIP3NIHg7h zdh$t1ms&tM6&4e;Z*OgP!wpCJ66itCyBRAiMUd4IGWH_WOckY0r9&&b4X%HqPZ8h$ zi9VTO>~xXCihnZT$cS^lw<^Ng9(6fG@;;T2Wt>Us=|A`#{Js;5ZaJ$a;TPvOd4pu~ zz@{3(hG(y_C@zNIsPd=Glx+yrUfzw^y{u)3l8(`D^CLn}WLT6xp?ptdVrNo;()1?! z)+O4EQH!$rPlcfYvzRU<++eXX-Wf~#CQ6d;a4|j;4W;K|y;-2*0=#Spfw$O{{m;WF z$1-cW)|PKoZ|cjmRd%(}$5QH}K2$hbMEFv&)R;V#tHhoNfAqtT8f)&!FXYqZzXDqT zfnIs07~Oj^utr!&JhyxSr-fUGch(cmmS!kHICC29?V0EuEL2%5hbS_JJsDRcL{W~J z)Cn7E{%l<8hwI1Zu&|xdJrhywmdV`AZ3*OP!pQhFog${fn@Va zsy{CRj{l@L58l%?E+@yh%aY$?9bERtYFymf3V5PFI<6ce%dlGog6lCYJ!a1#>#gb& z+ggtNm`hzjYXs_4@jTI5z1nUGx+>3AJuTMvoY6^ipDsn8F0o1XkETR$ww#4LZxNVeX@m z5t%xfqYYbZF_Z?5Pq>?eHw>GbquOqo@a}X6qs^U}*Ta6E{(Q?8xFv3K;r{6Q3fA9o zRy%g{)8or;RP@`s%umY|nvgZ3eQHHG8pM*oL-UtdJ%?qXmbZt<4_<|Ox6k|W2!1c! zc_ZI#!QPR4gV4StAj`L+FYxg`@V;w=-fa5N%4-lIOh||bsIeXIg00B&eQ$$JNwFOx zQNHV)R-aJ#1hXFGudTKiH83pj!+iYiHcjZ*eHleGm=*n4+5K+|{YdZQN?2M;o`Vg} zKRj^_FbWPZYz&Z33Q$D^D9;9{Uj+2TL$EMxb&hc{yPVa`0x_A4D0(2#osiNh+}b}^ z_iUpJ!0Z%&KA)i4D%?VN(3Y^M{7V&J^<(c&5bdhV&y`>bSv*?j*Z#A9ZT7F*I#q%Z zCSgbwzB~3;M2dJG>LG!Y_a<{zeW>gA<=rK0&S;)g_H%u0t!dkZQ7wf{d?8A~T!iqu&YMa2Hv%&)|-mMaO zAul{9C?lstBBymCXI&%ba|1E#@s3R$Fw}#8PFSRN@+~4@0X{qtC|I5k+!H`=irzmg zt8b8r#Q_KitKX(QhoonD=s0`uQQ$li){sEpk$$!#?)6DR;$rGR8)qPoH>1~)xao|J z-bfqrO^oGFN}%`!RGI=+^+h7l~7M z7Zc7M63bTbiS2;~y$+OP+h-XW`)~mL{}8_o;BD_h#jzqdavkB3sug8&)m3z2R11glFzNHEw ztMaV3Aq3@c`d+D!#4LJHl3itC@-0Y}D9PQV&uI}Do`0E4OP;dMo6_i(LYt7XTAiXZ zogyGi|Kq@;WIN2IIC5?_ZTK>6lqwx5nm&Q!HCh$8rRiKLgP3Tdw&a5fn;$*MUyrWFxK?iO?QKqJxBn&dV9 zz7*k!5A389Pd-?K3lb-#OR(H&#Z}})hV95$ludTuAM_wgTq#CJNCP97K$czO>qR!j z&L z$_R5@I`qK&JsJ~r4*dL_-c=(J<(o)Uow(kW^xY}64dFLL>9}fwuU$a@$u(&s$WEtE z1)dm!>Iv-*fwfqv#)1gboK>4=ah6DN0xVPDJ1RFI!oxn5{^HP`YLBG5H{sr*h1w0! z)$u}a>LTx$oPZXO;1c>!J$h_vRS<+Q{$=`!R$EWW0=_GmNl$_2}oT z;1@eVDkNe~qA+daM9oz=@p69LMFGkw4I+{nJNs@uD*|3ZA9y!sVw;k*3@qrI3oTEw zgDk5?yQ}8vsQO!`XgjBxGy8nANFz~X<ERY{0dC8g`fVrl|G>g_`9-4ysgipsyDo? zpR;Xnp>6o7{F`Fi7u}HtmV(5>xO@Tshqkwjin{OHy$6Arp@)zbP!v#Uq>+{e0hMl$ zMp8gPx^oC=VF>B&?rx+HV@=&Ad+iB+k8OIy$&i8 zUsc{&x3Am}@Wxg7U{u&uQ1er*K=~5#kvjdbn6CazIU81}Vv~nu+tHX(h^puy@Cq9X zRk5S#dh@vJzDO67UKfj36d4W3sDK^G620wId@DAZPpTM$fn!0DYmbr+d8yz#A=Eo0 zcpT=38uGs*XmSf0{~@g%t=$Y2P`gfUWVf^r0yWWKJA;LQS|5aXC_7QO45CwZ;!&1! z(u66mRyEwqo|sqbQ8_QPo{HXe2E${IMd|LVgXa`kF zPC{#_RYdfS;+8%d{ZfsOuxIUS@+|vQH&F`07nutUWF1iWIHhDaprSvZ+B)@|ZCY*g zg{LR3d-u4_oFt}%9nR9U`>Vuk?{X0V>nE5~~ug7zOo4Ld+V9EvTwEYPxP2k3#!+v>U2L}ivA>b{)$iB@WnZL3N*e$Qf!dHDI7^5r!f--RvVgf zaqNXzi>hoea8BD+qyU1 zuPM=AGlaip*?-NIZRB@UzFC2k`vyh55VPuqH624%B3@>T|2F+T zrPw}s`#!N4Ds@B^7*g+;?qyH6-}l*LrhkP&gko(!VE=Kzi9X;)kF$F_=$c)_?zus_ zk5G4~Q6lKWj<@^NnfsE4N3>!`a`s2kK1T|MANt}Ah}B*aeQZ zMz(vbFLvf{coyh$7Myvu`$B}?4PviK?6IdB4o3Q+Dlr}Lh(u3QIZo5jCmHmoneFFU zz;xR1qLc@v=LC`CBaZc*41!}@b=5lAD1oY%QpJo9eW(M6>{!N zXBfVx-qe%T40o*=Dz)FpFJJ9y zbs~LZh%tP+Iew$i*9Y|IBQdo4?#;Yfb&t4oTnw~tF`gj|^h!R*s}j)#;gbsK&8aST zhY>$Cs7WSX?hB{o@|c=aBgz3iP|lFn{*pcr!zxu5c&^SQm&$I_*CIH%rjjo5_5|zV zg{4ZiXaxAF(A0){uF?m-po^E*>P0HWGOa?>TN)*LwXWEguWU7{42FZB3eW85)Ywkf z1zl>q(P?_K-PbBSyKB(mb$o*TThqay%Nq|O`g?M`Qy3F_mzY$0gk10;n_-_`TolEi z3D)&~3*-oZIz3C0QPw%$oX*qFH@|cF)@H66{d$(1G%Vw_+mUMyen{F0>T8c3J%xdu zpwr4=3WpnA>$%IuRHk8)fxfWo_I#uLDP7y8J8HE(lfuy8smI~we7PHa`<2(((T^WV zhK6F^m*?kuryL#EU@UkCnvkqQ9GmzFrv#Y7g|iFH90>1%a3oYn5^&XUN@3lN*dgTO zW~`Ks=i64Q@}t$==lZMzh^46v(?36>G0o-br?KgAl)d9gTL8B_!V#x`zu{+*qWCC` z$@hoCqhQ&GKIva%AN!S%C_buh;a22moT^gfY$vN8;T*qDI?6Tsgh!cw{$;hYz>kFL zF@e2Y(s9Al8XiEEF;)Fs98Fd;Ax`vwY~-^91@99zS*lkxYI5`+YQD&Ed?K5a=dXS8 z;Dtx*tkbn`$L7~_?0-w0~+ zA3^*E@76!n8+h*Z@*BQC{aJ75gT_wQ<6tSSHPW&F))k?LY2IKQLLV$(62_d@U=qR6 zCtw=Izt&(HBL)^UiFWq-5iE4qjL1+Fa5Nmw z{)ho6`RI#dW(MDDY&n3j#-xSxhjP0k1916R?gkRd$N`KsbFHOdN+;K50md4W_G+9Q_q)}2 z1-^$55>#a50jHa}_RmBur*}V-bX*@YB^w0GuSGt>G4H4Kn9G9D+4liDH>Vl-^^dM= zfYZ(6WNz z;YZy7XzfYA-i^}@V61VUdR31LVU9Ytx{p+5%6Wa3kj-@D1@LOBvTVmwFE#Csr?o6f zk7qwMdI`utiZ^FVp+8)h z>D;W$46DOr+0R!KG~b;6OtvUHU*j|OWc8w0dL5GH;PmEVvmn3hVymQn_hMW7^Ld}a z?LiCM^osSe%YXF}AoPtjN4*lU`XkHie-~k8Ij)Wb230iYs>kK8P8Qwvu1=959M@}( zUnpV?7Tol=**js|Z560V!Ry~gv-aq#vvqp4^eAC0!wV5Aod%E3c)%3f68d|-`>4MI zEQIod0bS1TM}n1HX6&Aize)X`z;pgBMNf}^_b=>hH*FmX^O?hR#7Xa7&>m(YmW5n5 z^#UqbU}PbkQZ^(QDf^DlxJL+mO)PoOenjL7-E$vxZ=4NhFj8Wy^VaGViM z!uo+;c8~oCBbP9+;4QX8wm|mRlbF)KD+o!N_5+R@!2A`R1VnAIfvc*<(hj|ClwGKp z2HYG5i2Q&gwL0AX0-C;NloZs%O!(Pfjsq??=)=%WBni6(;(mzrlrtwzsU}FejFRnE z+(Cl2^aFCD!6A*NgAWyYY0VCCp|6f4scs3%aBA#JM=9Zn;T8^mLBWWHQyu6$CUUv? z2bEM8_XF18P(1yOe(PIRuPXoa5=P(5{8D}LPMV`kg1`wkFntvt%;<%a-*jJEbk2W( zBwh}x@`gM9bZV7(T^=2yrlA)8^5su4Yz5WicZUe5a)Bg;&eOnNX`(tru=JojfuCGW zI!Wm>nKi7YfVl@zB=4BG6CYyn+`pGHvXhG=#WH%Vc9=R7X;na}F!`sK@Dch+Z#l%& z=!m-B9h>piQiyCqHig))AUd{N`2u=-42KvF?5-AN8%oVx*&*smN#BFi!9@Pd{SZwsi(KjAf~e^U&90rh#j6 z4?!BZU5Q_cg1Dntm4tuJ4C$OyCD-2lLG8qxxOgk490twAlEihfPAIhhf+w-;+#EI% zAG$vQ=BrVA<%!0{NRU2aewgn*<9||D@^HAsAw+R;*~wlP@@__=>QkJo-jkYJjx&rZ z`?wPK)fuW08$xW$B?3Jm3~!A|xuk})h?_7A>f{1VhPSnpi6u$v=cE}>Q~iSqCvCCZ zW!|U5Kd$RkyoYa>SYqqNIjUn)VfnpyIB*`y1!kb!Y=L5b(}wd)tKWM$iV~aI59c#F z?v^|&BJTF!QiNmD9g1^_EOJFy#2(jk%Gg8-uKl3?de$dEr&kXn8DG`Gdk~hqAzyur z8^-OgE5T&Jp?Q|qPH4uSn>N66+=+h={<1RFY>$qo7T2Svd)VmufPEQD`!bcPJ#wfr zb#`Qp&r}($f`d^JQ?3}tFL6r#?hzX=wJmcS*of5SyP>eB0Q?NraJPI~MwEXLM^~x4 zuHh==&4V?FJHq`=&qU9onphKe@rZhE!dm-hcB=eo7gGf)$6DSaD|pPO6AVktb$)(bS7`VH}_AgUaipKHwfFnWWyW?}L5oO*d_XVosTL_8<2-$Cun8t&hb?o6s1U`?i+s6eL-X`&+P8); zNn=NOr$X;n$lV%c{Z-;tvIaJOZ$6*A1C2B=nn?vywmL7a>E-}6y^s|eZ53i|wg}QI9 z7tI>!XVM%~>+6}+aB+BDw%ClmLap2Pzr!yTkV4z8{X;@+=634 z+Xy{7tRvk}L)`jDlXLX3(K86210e_nhE|_;M zm>WDF7{5~h8+mBhV~8^c`l0yKH#0hPgEope%P!&$& zT?9Yf+%P_P@6>y%YWdvP3es>7vVRtYm8DmH;(4Fg3s|W>HMg0q(4UjkoT~5|$%nX9 zyu(nXFp)KIlP0P~LI?$LuppAeCQFh?UzHF!X1@;iiM*$j*a6S`1;&_R=#?P6Sm~ct#1Xz7?Z7ddtoN!+7H;HURk z4Ug<7j9U!J8uS_I-z0iPyLWc~pa2FAnRhc-h()eCP+;=nh|Dk-Siis2d=+8r#oY1$pEMlN6wWyJ)jWy-RXWNh*&wr*+fOK25rP8&r>q zf1VGyVuX!R&@*5Y)8$J4w2XjLrNlK7ZJdS3_YgFl@etQ@yBk3mRwbP07;Sei8gQHP7!YN!rj2Bq{hXSEE*OTwZ~;+-R`~MgIdO87(oyz@i|mMIT|0abP(VLk;WANyB3*XcbT_$ zGjJrJQ}AdqTo3hgm{)Ghhq!bR3J|CxogW7=f%6fflg;FjeLm@>&&6ktWZHNK%K^Dn zv6wtFBOC$YDkX|fR;4gZrt~pdflyiK!uZ^W2#=6#xzt<*p{I@}PsnRvZk5Sfq;T3I zhmkNCQJ9=s zD9Fn!V=Cemp8Os+OFcflan2V5{wX!JsHmx^@;>wG`AbqYkr*9bn^fI+p~4Ry@rS)d zEepUDTgvH6;d3EkuUMH@3JMp=A`Oq%sV+r<7sV6QrC)T!T)O0x&!4nx7QAjS2ldVoIu-Pw}tI&x*7>kP?%KPkKUC z)QVM1x>YQmRgcoDSevWZzgBTxR&mn+3+HM+v~IP4XLYV5q&%Er=zzXcM#$MZcX~Mo zDO4t*gY^u7rKpP)0}CZk%$3&76&0-2(5=-HuF+Pk)%jYhcUh~sU2BY2rz>1%u3KmM zxXwzk4wz!wUe;M`*Etq*zUG0rBIqA=(s`uOwG%_EP+%>T#PAa=tzLqkKk>ccU`JoN z?aunB%Z5mrMo+xP_{WXWHjN3MjUSpDQ@=K*Ts9`tG^OJ;F2QTAe%xGU(_G`(ytqVXYQogk%xJ+0mR`-1GQy2S=B0upw<}sknp?UZm?kcp zTXmVHgj;85TBjpgXCL28u@^5}=WnLiG;OQGZEM@DKO@@KAGd9NZQHqQ+r(?%rfEMC zZa>*>JC0~SecXQWwOyi0&>OG89}gtefs002o{|w=prEEGupJD`4@n1PfLm%)xP@!=j?oTd~t>vETc5zg}*q z6(^X1jHm>OrG>(Z*GCW`5%OuP67k}F9QXqXdV`6y5`4eG`ZqEmTwtQF3Ib~x5njNc zBR#km;sj=(?xe+`vfo2LCb5)}go&zH$_T9b60C9*(Fk%tnzP4a0;@t~q(^Ur@89%3 zpaHoHFynS0a(^cdz!IVZutH*q7r>U%0xeL;&peg)fPyPvkuRuZ?2n<7@~3@Rb$>9P z39!f)Ry`O_VmIwde^|{ln#KoMU&ZapWX zJ-fHm9ZX83q2~^GcHqQfiL@zPKZg@}p3msL1FSj-F2D+z%Ck9{_aa6^-ve;yRHy+~ z$TZ$xU+bL5XY@S*V@`)7V1-QQ-(TsAC(<W9|e!MXR4pi8oX~l-9=X3 zSRpfn&kxroV>AuDTQ5&gp8+dm=F_W7veS#+VmD>|Cj3EcO+^v4$8SiT?b7!sq>{<&UVtB`w2f zk=+eO@29uUbD|~5!*X||&f4hDNaNh{60qz@Jk!5xy`xXFy zqn3kwbi+Pd6Sq6~IZN%DySbU1nMzfZn(cpMk&i3dDk#Zl0yL0$XzK0asxE7Q`}U=1 zyQJlNQ*n9g8udvGq2@6^^Y~TK>DGi%nwE&*~SKXrP_OE(m@M8dw zmS_VI(ejFSn|A89cbidtkM~-RW^DFa&(@0f+J2vG@BM2DvGVv8%{Y6FZJzy9s4d8) zM-%n;^tNQbkJfExzn>w5^o8nj_Py z$*6q~#>;t^S-aD3?(3ze-@X1y9MA-0bTD50l{9?j2v(yW?A@~!_&*Ydv1%7}{+5#T zgp=I!johSvCk|y*DrTzT?o;9QmBFL^jsH#@fb86e-mQ7co=3*$`J&((lu?xZ(Pj9N>>U=F1*%h z7QmvEg{wKxMc#x8T1l1PQE(Rsxc}xwee*{KYyNRrWHWxmiM7?8o>@=P1-zc3{nXQYNs)?6P$OLyMT;@W|Zz%<_W@RnPfM z(n$d&A~T5-F}{jk*aKPlA@$UQgo5;Rg{BFxh-)Q7O}8=aBpl4y!}Q^1dh0egY;eNL zXJKhVxlY-I@gO%)$rh94#qzMZ&|%6%WZskQS`t-jE?2bt;;*jRp(l}9k(2oO0{!x1 zPSKORoq>#Jp!*D$-?&orwJ>=`<;Oi14l~YHR}{45(F2+$3LFG4b-VlKhIr=Uo}4QI ziNnxD@Eg<V=t4xI-V6Lj!M$dRG!drp&XAqxpkpZYX>78dUc$- zE+v>ZvNMfXpY&zF!c0}_aR~*FvUIDUOeS6KM`ce7QsvaCc*Jp;`^^%RO^D$?%2mNJ$_~)UdeKa(vkA^o4c4E>oX23=pg;R7?c3 zvN+pme@hUj(w>-EaN->tEpx3O*1y}tR7LWMMIs^O^K(%OytD^z+-8TrpEU9N4nGZ$ z&r*a1&zBX~^w`-LdZwOUoVJ4qIgeB@gV|SI>u*(=a>t~CbH`qEz@jSxnbQS7z+HEt903^}KyS;J(!GRpmQJ4kD77ybnP_c9S@a?N7YChkbrs z%duL47u9j5e&v4`ul93gT++xYld~-!cC;nuQjab+O>Luvcsbq2k)Q0I=fg)HI9Bp_ zL;eg^-I!O8LYt8~Grm9r(jmG!z4-wRr&dz$%3H^GrWOlNM&=$k6V?Fp9E+qQEj%%% z0Sd#sh3*BaL6ZA5xy;H|G(VVz8m51VZ&A)-n`#d;m(&!RnT@>UsuTj-qBNhIs4(x zQbgmNh0(g%cMwrS2gW(6S*FIk_xX29`*mA6O*Y8Ng0==sNk{Mx*`p~Yfb+Xh6!bN%cmRq=mBH-HuL zq^|nv*P9sOMOjIwSNUnvMEj4lzABfroyy&FNye;Qlz??Xd?{%jCN5tQmh^FWYr)+_ zR_Hm-2VPts#E-Z9HqU+?qu*ZYz$%Nx3NjteDu23M(+%8L&Z z2JZ*#thfEKLbef!qCz+T3nu!k@5TyQ{u_*=|9O~Qs5@%MVKT;m-@QVLVB0AI#2JuC z;Mt9)*_n8_oz{M|A$D~NP{iN=a1#w0|8tQ{A1^oX>b$6l+aS;e{ zWg#CAaY*g5l#zY^av*SlA65-gurk7VYm6hD?EbtV6p+gMpyxs(&qCuiLK7&%K0w2g z1;P>=!cyFC;BU`ad~f+VK6~xE(F=|74KGR#FKGxbn+vZv3$LP#s1b;$(~4+tk7!Dc zXlaNjPKJi52J9Q*c{4!$7@*!2gf5)~fgRAWiHM1fh|dC1U$mm8+@ofaqvjf-7XHBB zzEej3WszTXk6ufT-e`#4nv32!i{AYQ)%_$n=By#+VlL+QSD9`GA`8iWDu$&p1! zfSUcOrUKRx9R{uVBrZ@ylBAypt`I)CBcd+=hl0-qsLH0QeQ4q%S1eA)xMyOwIc^O=jWnM7$rO_B zG%6rRO5I5$%#Iu$bt*rtG=KH3cvijQO(rP9u!RdUCVs;gX9EpF^tX(qH*)8V7^&E8Y5sS| zTz}xx7#KL9&;PPYeZ``rG$Q;5eENU7#QxhV)i%ZW-=ly2!xH`QxBp-0pMNj0$J<&jPf;twna|tXt}f3G0s3@%JMio8zn0j4 z&_9LeS^bRhO27!&o0cq;Ax6Xmhw(P|#!?7FFw1gi`1yV)-%)bFC}$pY8NrYPbB+=` zbm)eL{R{n5@ao>eZ__cM?GY&=fr6H7bBNLs3d=55+>T#ZLXy`T$~MvpvlyFU{X+wA)$S(IA&sLmaG^-M{?_ptWYLv z`n$6eIYa^@Fm5bR*!^1_urN-{=$t;5t<8Ky@S`FZ@B5MjnIgoqKBlQ{E^{^kDG-s@B9*yi+y8cRBYs<`#oAJWt?X?+;y%-%`&j zE`Cog)&2GUcyzn0W-0Gq?+y7e4<^MH(9X z`_KD>%m3Q~9;l7t&-bls^|wUK1DctiT$L?H31Af8HN2B#=H11_Fr0t@b- z>v6!U`sREc5i-WN!X!-hZZ-Sn;wgjH_dq5?d<7RuX5U=?B43ek5^;*yWLU*OL|>wM zC^F0G{N@hEpE$iZ9B1ASQzD=HfXN935K1 zhypwDO9>ss!p%nMFQEhSG6!+VBWC6#gYwT7)Z!8NpIC@q463V>X#3OtSz_0_#Km_; zynbvUKdck{DP!RFW`y*do*+S+>#^!yT|fniCw0Spky?dN_HHRiMb;djxQgVBM)bm z%>!)0pA?c<4TmD-oj)lj^INO|HsO4gOc5Vk0tJ_RHH2&k6JQfARL@t+RESb|TllI# zGv6Gr2^VXW>$QgyD7qGFS6KAt0XE@MoqF3JLs5!urTPtS>uZ2bxcr8&k4vcJUT)kG z0%2y4Y}g+TBfhKXBKJp~huK4XX721eW-n^*jhX5~`{mP%Oui_O+O3%i?P2eyUu%EO z)j0nsi}I}B{oe5o5B+qpejnKrPtOtU)p)Qrk)r4$`mOPJW2)M)JolO^TfzES7IfpQ|`w}a~GP$E6U*fOn9!bx%xp}C*?unOQ*uuNix_d$~#Oq ze6x>!lsc)jb%l3>Y1%f;(_Ol(*3;dF3N1dmpELqV%k`!OBJh^=1|kHv2uND^nt-H5 zbjva~LDG6NH%XxgAndy~S>=trOI{1HNVIw>R^+ymfyK@aO&B0=ga7CwM65 z+;I0_ozq3st29O>e!Y~c;`lN>s!FU}z2usJI*-Dwq_UqXNnk^prcbA3tG z@WcL_TE{Tp&*Pf~Y90KiCDr4CkkP|&X*@tFEJG1>^hK7xv}RIXb`((ODG~tcJWW8I zr|DK&JF6XXqt1&U0MvO&Q6~!)^`&)Rt@>}&dBX&NI&T(G=eeDtOY48UgWRa|@BqSo zu#^Ot(k`8ART?TAZ62C$*Zj-Z#y2*IOy8*T-Fl|ySmOpFhVEiBFW=x|E4MycXuG_* ztZAoWobK{h-MYc$Zp~@5@Lv06Su?7Gi2nC}AG6`_gC71Ek;8G}@|L5C=kzz~Jj1J# zDYuxXr$0Q)ThEX&^w;NW`G(gQtMxIWmwV0SZNE|D^ysUTbwl*^(P@kr8hw~~jRg?) z!6;v_k`V@lTnG3b+>giwaqEFr3s0_}RBl{4SU#7NvKIs2jD>bt#=U_gd?GMR@h;Bu z0m<8dI*;A&TpWlc_I zj&@JaxpSJ_FT7P52gYJsCmFJ&R#YsZklX%|Bn2ql@4j#T!J}YapS0s^K40jR~X)BDBOz5f+-AXKy6dn`7 z)JIvkZ!M+o4ylGK)Bx%{78!nz&(S(ZIn>9NauP#o+Hp%9XeF!8mW@n)Gl`7z5(}k0 zoXJ!mMtRa&sNy^{nQ}pvFZRt!)f0Cr8+W=u`e~7R{LoaE(Cxw~TS_01Ty-CC!ehJ8 zZn=6L^|J7qVxy}fEqNi0^0?z0o3PgBCXL#}+ER!2#d`BXn)P39=bFl4Y8MSD4Lv0l zV-G4aO1gO2MOarD!p3SsFg({Qq)`c1E-|B_(H;WiWl5#1=4`|Bqhr-^FMbv?l{b+$ zr}CA#J+;$UD_)qYI;ky=Dz$n2bzuSB3^y2g;aZP+ zU+(3pXm~oq-*)=3{QYT!;q}&EGt)ywqwh@dk2a>Wd15^?*x{Nj$U7W?^sGy`vS(dn zeW*Zw+a-L@mOZ@2(n`t~Ps9}c#l3@X%rVL>(vW{~I7S0#Oq|E|g-U7c!1KTblq1&IS z85+Q+SRLe(?tvt?U_TY4&c7rQ`clcL;YqL!W)x?2m!starQk zY#$J;F+d(OWd$le*B<(=;m_a!_)|*&Ef(~xWV#?d=h1Ug6W9KtpQY9wG1{cjO41x2 zmBCLho5$U+-8wMF)^7;=Pzv`hoHEpnI&ZO8k`4TAM}qn5asVJ_j=2jr72(9&!2t=( z_~E{gqn?yT!{k1F;KX$z3#8ju<(D*K{lGs_N?z*P&ub7Hs`d3QaY+NX*ye7p_Kpcf zvx-o~2T|KR3eQbddylD1_T!=#S6eFMO*XSek>-Zoje{Ags}}JAVH`R5r>+h_(_u-3%3?|Oy_jv9 zo{l*_YWDdPvaEBUFRIbk`}9ja@lV4utxV?2RTVMdwtW>IQ!r2fCOM`YVJi%@RzY}# zBy7=y#fuYRh!c$%;XH%ARz*_v^22sy=ni=x`>yzthxkS2gw77Ki52$x2w59(=qwDh z>OjzJgyRken<5~I6?hO9H$MiCbclzPfQO7$pd3q}f?S}Yd!RCqvrq;FNJGi^aZ4GY z0N>x;K`7tB(VtVLnA0)_1r2b;jhr!=6(<^1b*-JizW@=Mi^J5B_>C60G|<=2kbqqk zwmnVpNrSJTjt~j*rmD7na0p&SSXVpnVW6P3Xa{p%Qvh>rCcFya(Sj`+;iNf)VRhmk zh4^G9`(#sw=UV#YScZRsh8Hx17tMv!2iXFBk_U%AQx5O%nEJN*zUNrMpGD%Ep5jlT z@TXmG+xZe~qoBY&INrz|0kT%;z?nr6J<-B`nTHi-gy-3y?!!xGffRLQu-}Edm%Xqr zzy!y`t=D|b*AAhpAfiKLGzv+W0}{4TeIJ9A^>d(7VF^-gh`FAN0i6fl8jQg_kHMyb zW2L~8enQAYJPLVm15ScUyWxYumabS}e|W5R11?C zRjG&oY~;OeXfq7o5hdksM74?xFLVz#7z{TOOf=L^G_guFPDwO-muP_#JFAMvBo#5s z%x&RDkbshW^AKjR{??n5%daA7I4@}yfuqf+r}&o^ffHsYPOz(rm&1t@55g_<#lN*g zxCnl&;v2=?ALi>uZ-fx-gj*Lgh}JULcY{PtVMM!@gsmtj`ZO8^0v8|RwmRTN^uRyF zrUDK@@O&ENZ*^YVyYw!r^ue(7GR%lHPT@3!t-osY=S^F8>0pm}+kViyG?$WN8`&~FxBOY2PL!i?Q1qPXS-?_-T|0tVbOL2(dBkYB1 z4gx0!6j6%=?0KNaMYf#?sYvmVp%bVi56%!dXdGmxClwj??%!60Ex}C8{b+^_%|i^a z{W|{&spx7R$U{o;Vq|n5OvoD;Si_((fL_5N^^aZwxUu^W$ph2cJSXaWm!@=A+M6NQ;0a1{x%y%IO$zf=@Uoy7HH8LBb7X<%C$v z#eg}p#$|qbz$ahG{pk^Rz7U@o0*7-QpHd3vK9yv4!W(R39A=N=2SV9Qsl^RV#cf5! z9Sg;sL&c3vwpN^6Z*Yrwov^9UNua`)Cgu&lYi@LzK24E zmre!LvjSJR0%y1aPqCsOZtLG6=nju%5yTsk!6APPweJx0=d|nu$uX?QPI2P-dI;J0 zRx)g>pF7h%o4|SwcY0)jQ{ao6j^@OPL1mXr&pf=c2ePwN_KOM63Cx*wf zaYIbG6jDKhGA>Te>iSD|T!JYILO6p0De^;hw=mwQQ2Xz4fp>ZE59)C10zQ?Px_6?U z7NVjL;We$GlvZT)C`vH0!rr|?dDk39DBJ{AY$5;%`<_ijmlee}O(mC28LvK8g$Tjq z2}s@vmCF(&BIU;6ws)F^#wjAPrm$Cxt!M;rP@DB!Db>60oIWH*y%(U+7f+B0ODGEF zR-8{zks?%=C%AivTZ&fT0%rPh%)Z!Mbp~l|#)@@2#cdnSZM$FFP{QqdihN}91e6P0 z)->FS1-9P4(wLMHET+EJvivrjw=pwq`y zz%B)JSSFbgO$rKxL9s=+$|r1{1*Oek9r18*Y>RC!3=#vkEl2P>I^6br3?qny;jzNv z)(V5a(j7=aHazgZtR$P7LsqNts@@S)tKu;$HKskrPDT2!-*4he@9t^o?iCs7OCK3% z84+0=>DNnlP$WoI6)_gDtVOAZnAN#Hw5)I==oiNsn(?>@pi2mDxwTYJXbY3B=)F~0 z?Md1C+7WD;>)yf+kzTA`v5F zw$tP3(^{?5BPG-NqtlvK(<;Ycuz+QK1&)6O*xom&QP{Gzqbj!M17_A|(y!S5svcMp z(5svow!{`U%N7=j*SaOuhF)GdLVUp%2_oyxVXAnFDb}vdd|{k8ew^Gf40-fD3NP$n zNTbh0?>{51=Cgmz=PG~Bv0M0bXW{uFE^}$g+ieBdL)bWCfPx3tMZmX4na>n;8v_AB z_(tOVw(Bab*Tu(mN4l*sz%p?dC?Z8seD)Li_NII|Bt6(=hq#>%c*tCs@waaV=wIKA zuD&e;gnhOdq}}(GU&1Rgj^9Kqn-Q~L%<#-xgQ{~NK;sIKwne+bd_P)cJc0%PqR8XJ z+lD)X+XyR|r0+j`|0)eu6tw`mMv{0hkY+3ZY<=>KB?~HOyRC1ZLjXELaoh+$62%kd zfIsHImsR46{zxULPj7(|GvLFYVlZDrbz7Lr8DH1}KzlG3!O!>KB{RND(XC2*ugYYs zZj`P*`~DP=_%hpw*Z>ZpN1}EhdbSKIqeyZ`SWPQ7_&bGU?foq9622Sl5@qxnwZW40 z_chz=H7mk(8@hE4X|Pt$SM4`c!%>2Y>_S<(u-%ze|LE2Cq8myo8^LIUjnKA@knbC- z&Jvrk>xsljkF1G&yV^G@>&|VPDPx;X-!~E0o7rWXmfx`*?rtXTFP}?*y`#Ugk8FhR zZiI<$SEy`P8EiLrZ#PBf_jt+|s8H#Ol3Rcj2tW2`sBGnU?+npx4Se4j&e$2b-r1uV z>OS7auUyB96I9H=?y}vky#Do_Znu$ecZGeo`OWSsJDeeje&#zYL4~55eE^~ia|Tjk z!e3{*JEvuP=j^|(+xF1xYLGheU*BOxZ45RYTcyJPGE$f(?UKaok{Rxj`|MJP?Nb@< zQ`$do^=7~xhjki|C(@M|As`;&INiRmK298O_yorI9ygl)@Cgx$@5dg$*rA}|p_1tq z%k2Xkzh913;JJ|sJJm@-48pLEed-_k%IG5{`ePcgV-=3$SDC^_piRPCM>zYtTn7^R zG|;qfFxBEi0iP4|%oCCF6X72xmhC4t-p|;&j<=bPT;sr63>D{ERI3ih-bBYQiOziN zkN=vP27bpz1WCB~z!>-B4_LGPK}Fm0=Zmgn&cMc} z0aaiNRqJQva1TxbOblm?e|jl=&jRSrTI|n&nQ5!o?~doct!2`>$4uUtT=YT4$L_J! z`$O)$Cm5-~%M*u^cS6%R@%zvSf^Haef{1P8-Zfy0Z|{1G z1C0_x@B5%nGSSEF=%XKKP-XB~s^BdgYE}WxC5a&D9lpQ;)n&3zU#E1A<_4};OxtT^1pS+$)Pa$0&OM7Z}J+;ZhqM9Kc1oXwoHoxR3 zM~S}Ldh+OH-pfxKA5(8{&lKyIdQWfhvTBu?^#Tnt@?Q(pcB5^tw)xog>Rf)#d`zR* z``-Ni*Y)%^Kc`V!AT|YEI_3UycNBq`#*P5DSzp2f&5U%a!?od$Y(6tPf=?_*?ki`? ze7vaX0ueuMsyn;5XS-01wj1udqwDr-vBeEgXV5g*1OEU|mQL^8{_02)z0Y^rCg-im zQpJ8ceb3{a#d<^Z_d6|a2P>lyvh)V;&yRLyGkt#0wRv6~{u=J5H}tvs{rmI>x?Bu& z#poc8i7m}3j*X`T@5CnZs*r%*$#v-Z`m&o-ir|@$)q`XaS~lr1IZ``mVnyj#>D$ls zK1)-)Fyrc@&<%8yp|a_TmAPZT_*sVDd6jE`-V@77?rwy1oZN#Ly)Sai@n+mZ%xOK2 zQjg0=;D{O4goRRp)wd7g`$wp2eC zMo&$Si(~wZSCzoK^8~FXP4TowO^U0FTV4JcH_j)btaNn+CH_xUFO;8-K6$Ak_Tbe^ zRaxHJmoGJ+*1meBugt5VVfg8lhL%Nbt%jakYptffMA{QAV_p+wHQA{5WZJK(loRJ) z$Gqj!vCOwiT(BtaJ<+lIG@AI;uJo8s&!PWU;-dZVvs1mdy~=Lc#y_HJx)a1lY7Gp0 zdS?v`k4H}pywS(}$oJU0ZbkukGL*=G+bYRM!St>I%Rvv`xSLcXd)=kjW_gqJqrd(V zFoVlpH<-mdC!b$UP-9AY{Xy42&^+0~yU{$!K5>36-H}PqG_$nio-ZP#EZZ_CMn=f$ zQ<8a;Reol$kab~xUX!qOae1GRO=u6EYE*{=Rau<)D4 z^}Oac&3k>q_N}LD&GzkRu!uwZKZ)f3r&g^%|C0sdHb@>q`*;77g)<_7T?_x-%g`Xi zjlKDQtX|0<&lLYd!rtfzgv7G&2)`rCiFzq*#b%zybqv~-~2 z&6g(Ly}JK!e+9mdSAt?5sMrk+q%^;sx}{Vm^x~1JpP6XH|L6XSfqaulL$TGtT7PHH z$t|V6SsNpPDLstrpCs&w9L0Vpm9ca)5&v_Y`sf7ur?oSUXhkyJHzN&`015lQ?yvR> zBE1WM%FlhRjI8t3u1Ne>bq=nVIuu6_{;ghNx%+Hd(2PMMn&3VS*)4tZ5ObAH$CP0E z%D^Cc_mZUq`f8B_u!N)5K{x}+=gtAv)Qx>OnFrF(B!-1Mn?O2s$0SXH*|IS~`IOLw@opBI)ewt{isc{m%33OLe`QkG5WN3Y%F&z!?tbgR zZBWLoSF>=1_zs?Nw(tUHqc)l>ZXW43*(l^wg`#%z14y(LPq;P>XkqD*Knpq zDfrVs%`nU;N?(haI7=GDQIB(!@V8)^@yBbSG`o)#%dFps9O*d+ix-yAl!?+(O_p-+ ztbHvNhN`ALE-(Y z$Ki%lxlDbZ5W(YA)c_VQGAf=e);bUy3-8sV6&AkQ5r2a06dbdooPI<$@2Bk}?m_m# zu1UWBfAv2h>@lC?3a1R1cq<<>8XFgP$Jo7H-!cU3GWaJ!|%XsaLU#( zr@z?okStXaK0RQd?GPc}lo}U10#W#O9Ysp2a_+?Oe7=9azz%2nHb%O@xb#L6&UF6B z$FrdgcU87wTj?PTbwn$8_V?oS$L-qc>6rv^y6vDU`{}YBXAni6bQoe=iy6)pVzL6~ z^OvFjOzVH3FWuqDQijt*`@%DHX3deH9w(eeH~fPz(-)T3r#=4RSci6W9w?x@^L|t9NBE zt;C<4TL1itzVbLPK3SMU42CPCXMMoKy|)x<(dOv`%wO*$?6@x7 ziPsB0>-e2VQ^o9b1iGMcYS9=xIoZC(KK?J}G#pSg#Ut4jQU~+b@p2N5(5cpxSv;`E zB>fXz_$avu4UIG~3)1BoC8H^Aw$Fdejj&kcQGW~nO4bdtCX9IG`?mKZ$x%!vz4Ve- zc*|1Y4kcw=zzPX_sG+}{1vPCdGY1R&x$1k$ZoB3EbreQ(IXQ>bbZ`))nZYkRFW|t8 zLSTm@%$UP+5gcOOxn6uwU*FA$s=@3cU zDVqj*&}9gH;es*E(`DToYJWw3Pu`CHI7c=pS0PJc%x@B|+BP-xUILg)t1?@_`t&O^ zm*A#c&k_#YQkf>pgKcrqVDeTFw#f_}M8u0Hfs4z$S6vDYaKgZy)CA!{8s%48A9M~0 z5oB9TC;Y_Q*co3h<@ZS*$zD=LFbpcXsGAasmw z^>t)5N;Z0G=hIL%%5JjyNpzHetJm3>SY-8^ZJ|qYdWyRiuH6Mx^sl7WU&mkcex{Q8 z{Ct@jq1cA>HG2SK6L~`EK^T@_g1e{ERxMzU@{(fD(Yx+=Y#Oop!fRj8jP)tbz-&rI z!p?GgBieGHz*C3sez=VvNoNXJ94|+bq2<{H9~%%j_)j35_=pKG^jj>A>=D1|cc~Zi z`hXa!Oey;E@dZYb;okk&_xFvTr^4*WJkvDO#B}yp^!f7p$*3vBv%Nm?`)*$9cTg5Y zM+XH7Z3)b*^B-n+Nbg$2U(He)X4b%>$+hQb`%xA#o^rI{^SiXdqO{S(><{IApS>ku zh(i$GW5Sd?e=M~rQ`Jn=zwcu#9QvbaN7(EeFxo8)2YzmCa$`Ac@=17x?&asFrN zJV47(3|c-ecpdahdd+C0rrEmCuKb0@UhYKQ;YpDrZoU@!%tQm#6TDLSXVEyhp=80B zE|NiC-c!Gr>VAJ(y0DY0U)ep?=TK3We3SoKNOF28uHr{RoybdGsvnAUXz?o--YZvQx3_Ai;592E9Hs2IEjP%T()Ku1|%>wuDhe{0n1@a*Ro6szM#o4>v zmHkIal8Y-3CaW=I7(Ix3)zR5wn!ql_UQeZTz)I(7Xeo&gs#{0gtv0wFH)EbBJq|UN zTGm~XKCo5F|9JoBS=+vMNuaLO%EOtec8-RUU~XfK7$W^vhDzx^B6;8U3skLs&m+mM>&u2=lOkyBQ=8+c@zjGHqc*o zmsnX1F2lSLqGJV6Ig!uKzuN3*URG$H_=TpW^vF6q^)InDUL;8~T)2GwjN~j=3^mm} zM0jpUE?DZEbL#`LN+?g-&X9&S^TQ{1l)gw6~@ zT&OB|oxUMc)>%UMu5YhZi~tKOz)|bLK9(>1p2olF(qXbAe|70FgM<-T$qn4bP|QTQ z6BONAk_h*QERKSbbx)}2Qlk}g%vnMSCzTS*dIA&olSRQGCiR&|qZ{;?5>Mw0?AYO6 z0C#HCmK*nlj`L&JX?tv%*?z9H5)j80{_Mp8@!+nqUIO8yGI}<$+l3R^NBDZHLhRM; zyftFHwW_=|IefImeZsO#Fq`ZFI6AM<{uYiNF~O{QzGr&}N0))4ldYDt%~uYr0R=ji z(WxG6{=pX0`_z3~z8-uS4vzjD+6jK1M1EL8i)fMIRZ0Ab#E^#F{p_l@& zx_xU=V%vF$oTQT6F0ovXEOcqnR*T)%PRUzZ*$a+tSAQmZG%5SZF7zWJRHrIbzw>r| z1%45V9~(-z6`B$bc~)U!ri+c{t{e+E&rH6HI!yRszMFdf6V!;s4e2^cC+}>Eo@B?w zWOFHYneUy_OGr0Av?Z%BMyD}bj|By2O|*>UhV2F+w1yfB;GQKb{Wcr1=k+{1+oj#I zD4OjeTVo=Xt0PsXBGoSo&*%$86&oCFX*TL3%)T789~0 zogvV%Z||l;HWzK7=s4s1?jMMt_}-2Z)FGR0aOj6n^9?I_27<8M%9K6Uk5(=?$BO=3 zmMR+pXZ2I$2z$U8ho=&j@is2iJ}x&lF5fV&z%TCc8o1pI_nsU!HV3OqW7s_%96)Kd z;D9Uf`vOYyb55oe0q>zKs~$>3*rc>mtP^8SY-P0L0uglKhm#Rlrr8&+*X$&7;ja)I zvyL2%*kK7-7ET%mC#?-R?^BwuEa3{p2Iyc$9dmv^Rr!8y|NS!d`>O|#R3cFCBtr<} zt-#zbE{(RNEE2^v3gL1!3yL7d1yo$@tOPs56j)m{>XxiIHP$|G_OFfGMrobMAB=b6 zu9OuMNb4T`&^G=;Huy?5i-u>Uxa5S$RpvUy zq)^exsaB?%p9MQQr1DAFE*gZKZ{~uwqN{2$7dq3vU!;j|r+3HY_tvEMZ0Gk~rVnxf zU`BzOeqde(*pJbHA}5~uj!EYI%OsCvz>YHFrljFD38WJVdWQ*ybctWUFiTa6NXm$a z&jHO!P#1`dU3Vg!E4GtSgr75SNCSP6CsD zxBi?%>6r7FyYz`#=`+Vts;gHwM4-4u@B|wi5VRs>;{gbH0-2%pu~{p2Bsua3?4;sF zF6K31nwI<#unR zj3FhC5$C87&MvR^&)pnMgN~JtlN1b)RE~00+PtW=Q>(O>taNg$e8>W~h=RM%V1Ktv zwlinJtP$EE!LwI}xO6*pPbK^k%RG>9GW0K5sdS`LhORJWhF(>p=0G6sUh9=v3#(b% zI?nsW{*Iw83t%rKiS4R^3x*^WRdzNcptL5ww05Sp?y9zdwC+_jykh@5+G8fwRn-Ly z3het_>C>Fts@8R&auM!ohbh@Z{~r?I7*~F8xht>&QYjcNVIwi&8MBCsDfa{V$@$e~ z1>ZDeiByiS(~7+_NYAz&xta3iR|PJ5rNh-v=h{ja(nb(ZBL;mVM7 z&Z0{sUj({uG&AZr9)CF?IcB@SWVSuY$}oVi3RZB zZNgitSibt>%tUtMOiS}s3oltKKToTmy5@@>Bh*O-F*4BGUdAMUB66Q9;R@OAXJ5+O z;EG?`(CXU2`Hh;rZP>GI+ShF#)!TKhm1-&BwUiJoT?73J>^OqLvkdsBQ0b2#g&Do( zAW<1E!z{<^4q=g25A{}0^-eFRPM?I%Vc=Rt2dEjahx=tSu#P@-Jk={CqEeXEQmIoM zPliiXbxGT_>(O`Xs(0)2bZ39*PIv0gP3U%i3du5yzlDbv!vf{`blTv>Ctx)a1&8aN zjZsLV6MRUv+0Cg}%Db~Wzq7Zlw|};G@VZwfU0h%sE?faF@s&)$=>AfkI=iD+K-Qnn z)1Rl_U-+eeIk11#sek91W0bxSd?Nm_nYXjfzy+uGoL*(9ZeX}~;Lq#;=w{&04!8il z4JKz~%P6%_r#N3XcrUXbKG**%p&y@oh|qb6AaUryo62@w4ZJ0JENflJ<3SXS;b+dn zREfY@@9-OiLCab3uTfoA0dP{J#>jo=5n_`O&Y%&Jz7eju5pHRA@|)qm-f$5$6={O5 zu06ayXIQpx_&xcU!kaNgjWHT|o>ZG#Z&*B0RwSy@JvkoD9N60I5x^Zax*7TMd*o~4 zxC!orY5ll_L?7S{le6JzIaB-*(){ZU69nFQ!)}&JiId6&lY#Ys^4Ug{QkP`$-%CRjB@@@WRJI@pUs{>^WFwvqG^cMm&v|-6V{X!9 zZnl1I9$7F)tT0<>Lgi3`Za;^oy{xZXI2uvEk*3W@?oz7)mnik{@>JfEfwLMH%d9+h69DdC3?2QgC;f zUvu?8Z&pYJugWy6zAIdPKffxgxh4-7-Y#p3No(rCYnlyfT7_#L=hw6~*L5k@^<38V zlhzsfiGUts{ej3t6Y-b->?}t zzZv{zGn8VhWeI(~k92kk-GX5&u3;--ek=60sbb~eS9O&DoOBHn*p zQ~h6amiK=@>i6INi@V*azlVJPkhuSlxc`v2XLqUI|IbO>?OPJprD)|g)q6+cO1Di_ z3GHVL5DWlAmO+oxvU{S~t`Mo@dqkZ8i97h%^fF!pP#waXNXG#7?0m=-}fu(mA zMz$f|!PUJg{m6ZUY^OBzymiS9{!o~>gE&Btw%ugz^UBk?an3@MY3U8_66!8n)`-(5LM&A`S~9Ir8LQ zEP;N0&h#x*?@2GBtKX*PC~@CvuI%ufYLL8@K_jA*5@CnL{Zf-gIu|3#p3F4pVS`Hc zt5Kib46nz?PX0-JjR?;sUWij1V@`?~+f4=sl z7+lnUtq%Y-!9sB>;yyyUv|(X*A1U_Td>2(Xa&K#{Ua~nbv{zRsBG+SNeWVB}b7N{|nJzovu z-14S8sGbwAL~t3B6WEca`3QRb00~@yDMygI!x~O4f_lS(;L2jP#z8&Yg>yz334SMIR#{ zqT80+s8#V1IcqrYD{^7BXKe+P{!`|xK!`;%ZD9|tdFH9)9Z-YI62Y&)gLkuL2K<+|h4eV(vCBN0=OV>eTg^4i z6@;3r3-VfSB_fm0oq`(hz835Fc<68&bnAh@yP2XOJ1VTv-+vZ*#6!Mi=iB_NY2S8Jm14(@lbZ$#)e7*OW4Ez&d@*jA z>Y_TDEW_3K79erCZ?;%U9tftlg>lx$Em|r67S^UXiXEw=INb!Zbx`bRnhKH4Loe-_ zp*canP+EI~xigbx&T3|Ec1fA^1&@=$Ao`^3rsDJCI+`Jln`w_u#Os##hG4GeX^(c` zwnc4W^X9S~q5j*gmJy{)yIV(%`8m@Sj2eaA%;M10+0lbmFN`i zcCKxAX$G5{a6aF6KGFWlZJC<}28?_zrH}vvunIc#M}s zw$gg($3Npn4*cL-jaA4Q*B$sFFx4tpkeH1ojkNYAY-iCpI@+^|h^`gI7%D_Ujg72P=#zEM-(n@c&PFNtdr^h4=gT`K0R43qv;hU#gVP0SxB&GDy zn9n}g$Wqxa%FgflxvL_THPI5i?NjX>bVrSGcwjN?THF6R)IayLze|-z9kuz!Cm#a{ z9bpU|z^+c}fmh*PLNvA+9)69)fz1$+KWCm= z?8an(n4-k-uu+kqV)+zq%})%nf&0}XtTs6Ux2uA85PWS_wltF;<HQNi;9GCAY;S1Vm~@qS!0_M zIc4+0-cnb_gksa80kCQ<^WMcKd_^A?gqI zZ-`X5A7XO83xD~FRvJ_smron+`_QY%FJX2naXyw~<|B(dQat2-7*R4ythpR@WDZQ+ zKDK!(sr4f9kn{WT6r24p(IcHlt`Q6-k6-2(Ci>VXPQAsQQTdMHkPL}SS}&zKeT?s} zBhZlT{_7&CNhQSrt~p{Bj8~H)J(WUsnLh_cH=Rm<2`o;f zF>2tfm8nI~)6kg{k!Fe7Drms!otP?G2qSD-CH#afir*+)?lS#7 zR)(T#hO$J4Dp!WOLx$RRhDKb5&Sl0&tW15?%+C^;MqHU+95Ri!Gfm<$&1+u&50EwP zKR{MD>1IweLB{CWeQ({3JChZ8i?U{gkmLk&^D5%<{-UgnHMc112wK!F$_k2# zqm=$NnBPC051_2WB>6v8sm4_cCb#zH%PqQ)D7wBZx;0s`vOTcwOjbr*OIoypWLO9zhUp*% z3f%6?Fh+ZLGI4wf3HCeVeGEeo#waIRnVa;bHiot#HZu9&SXNZgBvQ39wk5|hGr~KU zH5Vr2R{GavjlujkmlfWV{W_rRJ$Jc+notXu^qtF!?P8`Z1d96WvbNORx~$}C7vBbrp#Du%bORZ{nqy(T@hyRmi1!PvY z2#bfB;cgYo##M2()d^SJTvr$xW{NrcXxbojZ8!9v_aQM|cPuM^St4m|DR*uBe_>f` z^X^zy{%T>f9{|hx!?CV2z7GE%mh~T&^&gh?AC~nWmh~T&^&gh?AC~nWmh~T&^&gh? zej0Vr$S08Deyq@eIm!{Zyf7k_nC{ z&4RMvzO=;@E(~JnSn%w{^b!-g_vT=TV7`e+&8L!fXox?ArnS=^ivMFlKad73{yq6t z%46aEi-RE>RP=lP{1qpaWBTnVAb(1B?r~}cyY1;kQ*`^sl z@Ivnh-g$uFMROAyXmqdjApaM^3w>})@HV?U;M@|ti^9Ht3EtFpfZ$E48#5*p3%_?` zmOzNVA};yhCgM1|M3GVL{wu6r2tTY<8l-uMwoj8n3BsJ1LNm*qDD)*qmzfIA|;dB?(egVbQgf;njQ8(a=2kJOCQK_gV5Vbyh8+&jzQp z^WE1Lw55-&B>%50-v4t8g#T8jOfher43#X`a>*OnJ*xTBLV< zqj{Gr)n$><6UBJPRA%-BvjxnX-3GW$sw6a*&_2X zM$)ug8|p94w&yy02a+1DF9}W_wC4RR6!5WNC3I6mnJgK1H++s*^uS{4T=ayBLYBPn z6~&jlALt{Nd`N6MmwYLFAj^m+QR2&fRGAzfLGX;GCvH~fN<^+Ezga`c&)4i0f=^F4 z7DBi#iJyh?;M3}b2~qj2eiLIe1nQ$)51&U!y{G*YDX;Ih7Nulk2-HU%9#X}qf1}lp z)y?!~_W$o&AiQ*j=@F@8iUD8m(NHIR-8M+mw9K|& z&(@Ek%gy5FWRNmD`FHd{+ ze*VG0*>Ai@B(>jkS0BY<2g0LJF`R=|d?l%aw!8W$i7g;hQuqQw<&$Ws!>+sfC|xCw zIoXuv6^4TGtbJ%1OZ&lA=|HS8dW>n(#YU3v{KaNMG?Z&AE1scd zJG&_9@>gNU{N+ym1eE)Cu;Uyf+FF<>sL%7=*g~2TsQ$ zlCb)dQ7wR}oVNXbb2jNa@aB9aV6XmS^*g{+ZdZQ)bG6wqzr)YOO=3Q=Y*NXJV+H>LJWGL|#0R#OBD3ZP0h%~Z_u3~#UV97$exy$$0XEB6N)Lj* z20kU(m=xt|9tyU|PovnY>fkv%2ywxAMgg7@6TCMZ>Or6W1QyGk9Ml9IpL%$>DxBq| zovSak+C}<9r1j~8gYYDrPt;<5t?xa)MP#vL(0uUg{*ZYXSr`adyj=iO`7NsQM+UvM zUysK6VRQqI{wv=ufT;|R>0rrZjP&dML`4ua9Vh+{{*4DA&h&~qpr3d=R9w`dEPhcq zorCD1j79KK{02QWS6^4Zb&Y)DzEl>^%)@sM{YQys`P96>@AfI=zk~X+_^+ycnE5-T zzs)Kn?h^VY>h;9JO)`aJ-wqjw{zxS%-xOx`9}3bxPNTpz6cg(n`sNXlPR*Jl{=t7Z zGV?ftA;?hDpnEv3`51?5rHde(IxFG#jzNb&Cm)z=)CFS{U@DE|qH{+xsel$p5S@Gq z*M}Ugv0R0EqYst2V}<%BdCIsil#ZsNmaccCUk{JEF-*xOBs68d_I@F)!!=Pg{GrgU zK2P&^_e9MlYmqbhj@B91WFx*}G2%`BEmPUd#a8lq?wO2Hh5SS7Tv0QwU!+#R-)#S} zGTj?`Wg$t`!8n^AS@#Q!Bm<_$3)#x^f#8O&q}n8M+NPp1vf#slY}?$XNZB8EJi@$( zVD7%vf&k|)7LW60+h3nm_XHJMX3WfP3)O zONebTB_p6`_tQkkv?6w9dik`Wrrgx!SC7{9_Sw&yxgu9ooJAB;~hpS5Zait97)nt>Xi`lp6z*VY87hMuI77;1EdUTT2-SYjf zqbi*~F<&;piq%k`eJFdoI4)9vlkr7Ob=Ze_vI^PG>{6DnBDF!73_G~$M zjX~{VG((g%`h&@~LaoDC(O1@dgO|Tl%Pz-D+N~)U_;HQYFRfWE`$*a9-ab+yR&8l3 zc2L6qK|Fn@TmHy;`TI_{41N57SYVTnRCRD+m#wN^de=N&ZFUn{S<{pF)u!lbcJJ%i z8I?J%k+L8HqiD9e680;2M0KL#)UK0{!6G!iZj}Z}UEM9#e;8ggyN3D(gSF5+a1Pg=m5-E76+4yo z(-DUnT?wj%fO3E}8vpc!oye0?6JO!tWs+S8{|{rGX4O_tii2dY^G$f5h8gMnF7&nq zvRB%1weykPae7F`rXsKYw=vKH`P7Yw9pJ&^kJx!z+GZ*a6CSpGXwyG27{|_0h%NrY zp}FY*Q7V0p45tWv?V1s0*VwWz9}gIu2frdJXO9?RZ#Kn{7Q?O$$Ju=K+ali`#*5~U zfBz7vcXSSzF|lFcB(l@k{GqsE!Y1L6q6vp$B2PvR&sWNxg|?orKYMZ_JXtzDxi~!Y z28{uBvfc)(g5XO!lU=+pQKt`2HhT=F&G4Zn|a09`jf`xj4WlcrWzC2dHX?LgFz8xDj6wW zCj3M=O^P;$mJ)>C&zP(puwaTu^NwWl$^vp^vcRpA*!ZTl7`td+&Vvz#ltmL^L=&P! zOD={UXk*@gizb|mcF7O*%|<+V`0be?f~p!p&H0U1F}ana0v)!gzEa5+-E#HpY#M9qvF09~^ch#&F{I#sB(1aFo)-k5Rx6ZZhg7 z0Ir5E96_1}vB}!yOp+s$P6O)UQG%3cEy*xQ^{ooYSpBUgHoorSTf@Wf#_IT=vk$H5BUZ=^*tcl%z2VM3|(7QzWZ8n~;5`ztep z2{$=P&RNO_u`5E+uK@UQKLQX8fn;D*GVFj6+k+891N7Z7C)(~|G8$JhhC?)jE*Vob z8QUQli!R8eIAK)VOB)rv+%0Bc5gec9_I(H=m@@dt6To6Qq>3L+sv;_SQrX(RCNZKoJU+&>>5Gn%2DhucpL#s+>|`la+Q*EcbYR+ zMon&d-DP_Hc6uX#RdVGw0hFU^zUO`L28ZSF)Eh5tTZIlmeoTBl+1&WgZq?C_CZQNv zz|+EvgIS5Cp_`l!TY>h|XY_X5SBUvx6Lp0^^EKbBltfi_U9` zK)-GWu0lsRC#M(+EyfWsTx47i=Q-`r-M?7Mrplf>QkP(mt%mHH;l8Z#US$qNe=G1B z>y7#Z_Nv)jxW=4L`8-5GPs z88h}OiNsn{)5Vc;>X8Islh6KYz!%*S#Eb-NArUE>X5SQwA%z}zdwIQL3a3KSl$ygi4rt=#*GD;B^cj?z=jSuwnDgOM&OXLBo#OJ zdVWl$=S9c4Fca*`>AZ@W_}lvEJd(7*6uV*NMMLliHnSM!>y2m@F-+!4Y(xiVr4mUC zb_v>$*Qs)Dv?0ELq|=xvu5vbSH<1Z?wZKzJ2wN`LX$<}L)`wdjvhhhCcT=`nQ?7AS zetZ+5QxjQ!6LDP=Dxf4bhL5^^W>X1gc&6p3^uwGHCiVip=gk8aHrHpS~!Wu7*rKu?dx zh9*jgh~r0OQ1A3Z?lk$&b_QN|`qm+sRZIsK!{?Z>dhBtda0JbJ@y7h+r|>~A z4oce;X0fdpexbUw19*a^8P-`h&Y5BOJ{O9%kKaCE9e=OE0W~TEn01fpb&ncOZ!2I_ z()V_f^`7cs|L_HC5`ap@a4X$7N!LkE@*FKael}$NOw7m2u@@Xz_Zby6`Q>K9Hj2>J zj;m#KvPKUBu3GS1_FpE$!f?6*)CbO;1}=9xFM0>Az6|`~kq6L2>R-IK=pnygZ4ds` zaBXP2-#%;0jA~}SIfg+TXB-<2-MQhYS(>sV_isaNrSeEgGEieSY$UX|Eq}NpzxT!O z;g>hVugOPPkbT3V1i+S2fl(PoYgLSps6M}EpD_;+uWyxUa@9*6aJGX63LIhuS61C> z1PwMK7jZn`gP_E*oBT0k-I(Uwn0C;Z*2{5*Nck5BU32(`8Dx4*+;x>qB{QT&urI@x zX6DQ$BaA^4wuuu?^%E`{BhI*!u6=BuK9El2x6`NgMPhni&ETV#cfOY%(|a?eEe=(6?sT#pC&jKj+r_ct>Y8Z%XI zCaa}qKG#ksl8houcp5g^MT16PHB}+Vrjm@OlICW6Z)Ov5=f0EAjlP*1@5|$4Blt|- zEs{f&i6pbjzGnW4ONQ=Sp|U?cSvyl%FtgJ)vs*vES1^A#H@`op*C74#C-tbGGtWiO zAvJ}$ zJ+DZYA6&K%8(|z9-G`OQsvnUr9M3JE;I6#>v&^!)%toeO`tcY`Jdm=%I zbiOtmbW1A21N9z)ycl7Z+Bo5hV8A7XF)S(eFDh%UsqikT&abKeSu61>=P#M-y=Ny8 z(7jSXM&LMPKZU`Yp9Q(V`oxKb(uifsF|cUX!_K?nsJX&1zv1?0!=r!0b9X`jm1hiT zKH0ALR_1fnEIpwO2xA&Q-yPK1uZ?XZ8Mn`Z0E{s z=TXc}zwhG{m=?`TB!S}1O)V`ky(Aq^cGs>i;Y9l&i;b&}EiD@+s6D(YyXh{o*}J>Z z*Ra!1@q3W>Hy6TOK>p=w(@Ilcd&p@!k<82VZ611B$j@vv_kBq1BJ|QN?1&Op319m# z7@5zzmnpNC^L4MlW$&b6@6rV^H;lpML#S6cK7S*x7uU@_EQ<+YS+3uQF6_hixbX(| z@wE;JT@PTU2M)IJ(=+QwXZEgV z4k2eQ3ukTvXC6P#ypU&}rsv*|&W(S*bz^$uF9Q#pUr3*xj4XZPu8lJ>h*KX5@Y{HS zj99I1IJ1m6?y?u*7MGddFSCDM<}O_3BQFadT@~|Pm1Q!*~WYX|-u@F5SikjHmS<@rzKCBRg=QwQ^VpyECdwv*Kn z^uY$0%5&L|!u|v=6bAHA#5;Bc^eGDEb#_|qGm6q*pTuhZ_2_sV9L_;9r& z^0``YLhRwE{x~Mn)7gi|n*$jl@zBJ$W5e-Wxgwi6lGB}ua^$C(;KcY-lldC+z0*0; zbLCWC_i3DB{r)M|F4T0tGAfxk_Q6lxR#WdCZFXnS=jjd0uI%~!nF)YNz4irSwLPHKAgkOQ&bYWM z+HBV8F#c2}ZbnwJu5E6t>;}pm!ibq??W>N@`FiCrWPITGByoxq2Y>$nzP# z_)}k=_u|h3giE`ghwB}_rA|x7mw1u&#W)-KGF_-TE;$@p!?qmi#}z{%TqdwTtj8?K zKI3&H&AF0pDZ~A%^+@K;(W>P;zALojcLJ!-tz?C;MUIiOf3yK#HqNZZj5>avvA-q7 zKNnw8+FjCoL@ERAL|(24CvsHo4J+#h1*MmfWAbX=Cm&Qk1VxUkXq2-msoNGrPN+Ge zN3}0k{OIaoNJO=)+3rku3*$@hK2vhP(#X?;!gr`jn-4T&?Y;510E9S1^2uBV)z zlYJim`JxauF2nR_MIc)A-G<93J1eS2(kQp6g3CC+a=ga4@aHMlm*S2GwO>jH*tku8 zOeofxR4mwVf34bxs{LBCSHW#scQ#&Y+JHReHfsbE)tNQJ*m=xbiK12YJMtj^C4;pO zd^l|XTWegv-Dt3OHt<~OkUyp5}R-Nk^#x8bMv7nDHj80RXF}RUEc!58P zN5z7yJNl*We15g%+CZ#;<%8dxKemRlB-1ntYFM`>a&*d~esfj)o~krnnJ=hi|GiM{ ze)M3MyXs)6BjBl4VIAkeMpp`R^e#`$$<{=k?m}Tb_sQN=lO>X9?@iss!A4J-R#C&7 zi?gkzvgkeDhMSACUn>hmKlyHu7bu-T&l~;oq8s=Dg7OopK>MU8n6*_F`e^K zT=FJW-2{T;z!Tt08B7DT#=m7R`@M+U{OC^)JPQGg{WQ8k9K+cwLEO`uy1_iaQxU=s z1?CH!k0Xe^q<9}ZbeE+fUh}|GSZDy=aCJ-F|k_!wO zzE)svB$$rVW+j@hzuic5+VayiA^{DR9h{!G`G{OxM0Kbz5Nn)qw1ob)g*)vffX z_qm3d2_Lt&GLpYu=47YXsBULxe#mM zrOPjBe8BysxRt{AOKI=X zGWYZE?HmmXAnC1!gdY|B7?Pr%V;Pox_4{~Kj_;-Qn1WV8`Ix%F9P7BY>F<+C9oLsO zQ?j8bz%MMg=u-X~{%SHmN+F6UFrdKu&(=7Y;rw5#T-1f<%Yp3wQ)|5NVm-lj_qO%N zaQUy+IM@w+-XL9WQP=U+vlJC`HvtU7oQ;2Ljkgk+ULSN)7hNCrvfsAG`5-=oCh0?p zHKluRDxFYP$#2dUeP8`KU;Qtw@x4D+`ymYupa}PkXFKKnF5|>|?f+_xgT))~k?gzu zYn5xI{ui#UJ1MylE5qf~D{Ne#HO}7I1a$ybxo}b-V&cvC4eziX{CITJIhJl|{T!+c z^29U{Zy}l9_u=SGf!f}-#u=>*1CqUvM&0yku5NWQ-mel64}Kvm0}7?-l5se^YyAPOai&x=PUZAR;T0Tt z-vb4FG6FN`Z(D!Uhf!5t6oi!L?U-yih@K%ehX1f8r7~YX^z^=bXb>8xlOcD{t@9Jm z`kU;{WUK7zqY8Ex6e?Nubc^kpPcDw%l*;0oxLxHcb5c=mF@JyI1HNAx$$`?sz5vV@ zTrQ8kgK)F?3BcZL^q&L+n^rBgIS2p4YbuCEUrT=-$6(b>n#=ijl?yjVeCTs6yQYQu z2^v&Lta~`B<~V~f$Piv))$PP8%U6`mfpwd`?obN z8)US!%sEg^yPhOIl%-JFJyz&&lE?dUQ{aBuV9zsFHUrSD*EC*H6Pf595hVR9C*~#Z zrySGU)_C_sgUz>ZB63pb8xCYimnX#t>HLpGzl7-(PF^2?d<88034>psmd5nu>$B%I zGSPpc&=c9V^y-=#jywI4E&cL1jy54)lqOB2B;UwD@~_tvtE;39dp+5eV9lD)T43fY zIlD|!srcTGUV}PdEMxJcYA~qKD$7_^;`JvcV4t2hr)O^0;jH${Zx4}ZV|eWY?;nfO zMGin~Ji2g+)`L9V)H~c~qvG!>*HkPzqq-PVv4#XmMeV{p((^`0`*Xr&#&J)*2`*)E zpfwIylkD&7R^EtPmVHyic06yPgfiVP(^l||V9n^_M0F79U4Bq|-uAe)nI!#4RY*~! zg0a6O*tS>q%ziu_`rx#xLf6-wQaIyu_@(G z{&L^w^kNVp!-DwvHtOXFGp`=P41|-nDWP^bJlD2wZb-bFHi?~M&a{jKur{Y0jA%$%0+qJ3Xjy}X{O!#$Z zTqZ+{tE_xbd)Bw-@&e!cA~??@#w3j?K}Yk?tu^_z-Qh!o@HiEH%(xkb^H*-uycUl6 zno#p#)4CN(WK}1tj73N^&nh+YQFWJ6utij!leI&Pk3d8dM3nI<=#WzUm1h4@#MSH? zpK0}=BlI{GIN}%Ps~HOKw@T-B-g>80Ga3&)$$j@`Tj6KTSXsYyzN7Omt%;h+TIgwM z$eSHKzS^mgew!bF({8F%J39kCs~LE+YxT2s?x^3k?#g-3b)t6h3JOH8c=x^d>Xrxw z>{_{94nmabR!LzO-9RNgf{%;1X5Y|&p>x(#)HYu=@(Itm0A=r_H!TDu4D{tAar>YT zzv2nqbV$Q#dS(5t<$&Yt)t}SGg_}L^N7wU2uIKI4w=pfJRc_?@pjJbabb^tYik8~s z-uOxR_Zw4d*Q@!B2IP6mJD2@<`ogkgI zj*!SdL~d8qZa3_17|L!Ch#NTD4O83==iCiS?2b+2j?3Zxpo##GUF*o!mGs<&V$z*b z+4T{P$Ky`-XLcS`F&W_EzBVR#bj)Vq?Ho<;~pb%{u9=eeV4c;i)6; zqr2&?ALH}6%I8z3kHMsm*}0EJwvTxg$%U;qP!qRP_I8-`wLkZDf*>3@5H7?BA8~|- zG6G?T@SjBZwVWg9!`x5Ve7}nOMkxD6#`wil`9)9qMV{gb1~=of0eaz@!NZU_h*B_fdA77>i>{I_|JU!OLpG?-2M;A?yHY^)O)2cIAYfN zqv)R&=?_%p4kgl24@w}TOem4q&Dbz_^^NTQC`e-9kH3y&!E<0oW~gT?J$J3nP~m7^ zxczDSt(H$Rfv3z1b{r$w(8z_39l_et`A~h_hn@`-gE@{n(DR9>mTAp$7^zviu9~^% zW4_6)fzTk^v0hBZ2VU$?m-wA?L1lKQR_l_+I_&4Cw`QSVXpV^St1D$`RAWk` zJhDwA+65DI&KaTd<6KH3yeqEHEHypg4(zQmkIg*xe6idSoX@qrGld~Ml(0_vkQ`A3S zJYPdDvelQ&*peHYwCcD7-uQ8i)76O^9C<8tn{;9SSU8Fgew)T8U9t@2ES%fzN)sub zir=j?kKIv^hrvO)be>Cmxc$yHw+yxExSw6rP}y zljWkMQ8PM-Gq!q5n(l%->JBp>cXSMkcpR>%i{#xbRuT>6y5a0E57qZ0B3uemg+4DV zjbIHFPC}4;S?f|>OL!4db?OMBD$?5tI*)b2{7knn+Wj{kyHuHJ(D^wdko%YeBxABc zG5wGuhXi~*ysyoss8wja%H=M~&XF|3j zBV-~h()9{WY{viWLS|}Il#97ypuZ^qT3Sa%5+rDrmE%XX)`+OVb(Q%BV#oVU&<+f-3}K}Wr?BS$)8%o<2$LI8Vr5Il!|=_}kEk;i z8Iazsr=A&DK%)iLs243`f-cFbk1?TO`LzLG*Qq-Uw!)|e5EDO}>tu)=F&ybkY7E&S zoTF!?j`Sp6vx0TV;M|>R=*RJqPTS3pl>(7`-7 z%h%UP5w0FdrBnj*D1qM}Bd4Vf#(RzCs?)I=H6?^HlO#IEJmI|Z_7%9%Vqz>2jUN7@ z-~#pFlk!Z`)a*ND7{qIM##C`+meWUCH4=D!3{x}7e#X6@HZtQGj#-B8@``BS*UI5* zI6~PYIid`A?I4lR0PecLNx-nL?0UoF9jA^=OH@lICHJ@SC#MNnv%g4~8YuSah9^R` zA+mB3fmzQAp}M~MuLY=S`h(=V!*wBDuYbUBk_~FAEEiUq==e(PylI|k@ZC+>{24l` z-`P4SZ<31s125s%M#SK9KMl5vFJiis_gmOfBh6%!ew)eZQedARxwi2PhiU0NS%Cqj z6HNa?5Zu~n;p+L58OCM~4WP5ACNGUI*Y^t?_u>fvuWKa4X~g?(cCe4=ccnqi2xvO7 z95BYa1&0lZR~{P|V9s`P5YfRmE*x4cF^le`i04i0YB4xv;1Yi3MFcM`H?}$&s3q=1 zW%@Cawl#BFH!Mrq)ES3DD!W+N8OPu`(k>*vQDIWphIqh>HKJG-xmcT{pn->LM1GLR zOoaH+yevi|Cwq8-Ok(7|O#!jR)KG4huo|cy4XdMKY~E~2^;`K1(zvR?^am;y2|P5} zB6v>|^5Kah^a=p$fCM zEH6bk<5x}+l8MvNtll35@+zE~H@RNE_*ATLPU+l#l*qc%j`m$^;glk-qG;4cA3pjAAxerE8MvZ znB1c>+%kh#&t>n54b2|KoVKj1luPIh&7D-8w(f^N@YuNb^#y$`_MZ zvc~$^o($Vn&Z=eQ_FWiRNo`7`%-w~v>LhrP~tri|bxW_}aR;)~MZX(QiFlo@>0 z6B)3pDi1ibrbs*++?b?8m2#@VX$3Bf4O`aO3fW+?4HHH==}=Xvg_LkqMX&VnGLi1M z9~~&9mmYrtUsp=MMUd^M@CT2r!>E>d^87${q%KH#oQZt5yJ6@>>yIdu?;6R533EW3 z?a$-n&pIEPrmIJPmb$m@z))!Qf(*1H*8JK> zppC>{$fVzQ`tK);7?BY+QUc^I8}6dI%g%^6tiGIUSzgdPq7YVt?;iENU3!Nubir7g zRNUQ5w)(^=Po9xx_whd9N$wap&gNBePo+Dy{6AN<{& zgl~m3Os9v_WFiy>k!5%}EnAHR3tJyEx|J_=&CC_NVnJr??G3|1^+6WzAHA}Y*z^fr z?(;vj3-kI2s>2m#OZTFC*F={Xf$O)N?)5G%|JDhWPYZ+N0T1bgy7#7(MM$6b>1TWI zC8C2RFVKv)cb^ZQliivF45PujXIv!^OiT$iS(x(r9t>MPaK2PspI92S*$E<+#s_@_E-~UdOMwXjUuN>-EggVkghZdpI#angduS61Zn!0a zZAG*Q3vmfWGLZa8*k8@YzN-mu>sJ3oD7y%!0{ zTTGii4PM&NoyaOP?L4q%Mz?)LxNi!;w zAd>4oFOhX00Mct>TqPDrGG(HA984VDu7ZRRuWAr>&Zx}lID+9YVkp7E=LlmN?0`UH1F(@Jeq+V^yk`Fvn!d#>gf)ljp{f+;u-RH7C0j`yeE)XNhQX))}VdLLLZK z1;u-zOo`a=TwFI(Nd?1ow$g6h4c|4_P&^MHtBv4tb=`Rp1FnrXZ%nrwNH?2J2VA6M z7A0V9r(?Ti;Nm~^LZ@#Sz_qY|r>j6)4sh?dK<4XEEL)rgc0pohQup&vuSWAVie!>p zZw?Xbh2yZa^$7BQAN>aN#RCsfavg@a@lHwy8Ci3Vl~x-yhZ!#?Ii$G{VPF9O>uO z`4qR(&!`K1X*7P&P)+~{&`Rfjp|$xeR*N-HX?Vosce6)?BJs82=VX(T5Rtu zx`h?6%v*dTwr8iNe#|RU)EIvO3J18!&sy`&Mnt;Bk-o`#V4g)tpy6-fM;7SEg{N_t zRLFXshk02LD5epT81w8r4mlThJCr!jG~Gt5kY>}nP^qYhraE7&S_pNoT12l}^hvdZ zd-bSXbOjo5m-4h1%@p{JxLf`~-2ID*S3?&Yla`gTHyXU3n^`0j(#PAB`G3Y=2=F^N z^VZg}$WpIVqX~EHvvp(7`Zkx-W|18TCW1j(b>XliSR|_m{i;&r7vrqWrO3DQDjOEH z>NMqW@p42;Ls)Y|#C$_kF;E0@gScP)LEM)z(1`njAk0V;mQpPG&gkQlRmKov50S{tfvU*us_}E653NQv5I(fz z79iqgqeJ*jG(La6D&+zwzEdsP+`3p?y@Z-?T^?#(yKG%?Z}a2_hbDk`(2o1lJhbC( z+9a!1oY**+*j|v-ri4s#&H8!j`en^cS{GE+=MA1$x!VwnWotgNsp8!4?LCwI#G({GqsberL#E!TsNM3L%9p+VFT%j zZixZrMKLW~B9og)9=G5*rCA`uSGIy%#?$1kfN5Z&eARMPz6T{FukU)Fx z6`p9%eT?c&2D8nRdtfYF`W4g$55?>a=?S2i#8L1w+cLtp$L?zdH|^}D~&Sw%NgBQaE`Kh$8Q7Q18haOQq99iVlX=K?{+ z$@)QiT7A&r?VZO2>iQ-mJ>WsQ5r*e|F8U+xseMx(Bh#rP3t=OREhDTo1elcseg}e) zC#KCAqKX7Cd=1=L7F^j{l~Si?xpb=Xh!~PjLHLIjR}y2Z4nu&nA6@=qto>H7$rg>WMA!wLJ@u~%8k|w4(h-&~Bh1#5cYlp=cusPL zPpYAvcR=Qi^N!+4L__aydcd_mfptpHuEWf^MSZ`zprQB3M__6VmkS-93+>!ext{ZX%=aZW*3dBH7FRGO=lqPy{9M`} zC;iJ8x~CG|ztQ*cR&|}AHea61-{||tewBqo>xBXCuRm43o}_&}YyFz8`w5M{a|)4e zpwai$ztDI8Kj^!<)7RU}OWey850@zomaB(v(D$|5fYCqbdrmbPeQ$0Exbpn^qjiOI zaphuU<-z_62mLCOyzK2Q&!ZGl8shsRsan}RAVmo2nA5Uc#PZ|c=jWkiDL3f5;T%2h z(qHI1b5r(A9~ylZc(`gny!z_?D(vC9k-_>))pgL+n$X)D^BrT66lQ0p!?*FVK zR?Om7;{H}L@pkI!)3eu=v?pOE;`pcX0SK;c5mYS-jvtgLfPI-+uq{h z-qOSUuLk=oZ}*qq@2`37@0RUvw(ajP?jO9}@#x@YpP+r^a62qIa@wb$Gy~)oJ+8{d??W{BE{gsB|AP4bpXp_VKNr3a`w5Fvr58=W5_t?g~O{10@Z-t9=p7vhTSX z`MwP$ptXULM*fYl_p-0*ewe5L+MEuP-&>An?$qH+4^gVP*X_+UyxSQaaj*Zr*zQe0 zqUzCbxY8eaPi<7cBn?pURmfOU`<=lj&lJm7qn^#D`%51}Q6y?!E$2s@W5sG?UaglW z2h!`3;c0Ht4QJFc9%GN%P}hKKHkc1Ku^G%4L|*{&BfP%}^A{ZHn6|{96^?R*B$xmL zNd8$HfUh7p>yWVld6`1#fxvHx;5EB(lpL|SaWs@ZNuP$i^YQB- z_jt;7I;Abc=y))%(tvrh=rmw2It^$seV&(Qw{nvP zT%Sd!0des+Gu$XI@-zLIlr}TNSm!pgBINP6au8Y<1-UUsN?W<9CUaYP?}PBS3z8Eq z3JbIT!`cATlihr&S=&VO=@(E#TH92OL@fc6;=Q^Br=7j}rJ#HJ4QmOO`;D7<#rsXW zbvygb2fg>cw;WGfes4WnFaF+kb+YsQ0|3Z%(2h-IbpqVN9YZq4U5JGE)nwg%~+6Nx`*gbd9u5B^me zFugT4E@$j<=BRSaA+9PeTkR7bCEZU~|40KqoD+MY_EWz#_Ij7be8GXIvh}#is;X~2 zedqk@^zSqv@V|) z4lHG9nV{umw#C{V6?d*pTt3ibZTtTt4XAENCYqRl((#iyQ&e<2LvP2F2nOwxc0ZVB zk|T?f>fzKqMCg8;_IM^jdoKnO0)Ep{P5Z@rIGv>y#JbgzY zspmdtpdTy_pGA9MP^;-EzJf*X@%UZxo!008D#VQA^mq_|`-4b}sf1fpq~T^}({Iq%EdLHTU( zM!P0I8f=?l8ILfBE<>I%NoZ*D(i~p( zJ<5_Eb87=X-}h0FsCxEDPL)5IedMXM_MbGM83j4_ix}Kj@&ZKml^R`0BpNr!Nuvs6 zlMtPo;GBEPb~cqyuWsH^)c)7yY0kEY)Z)ANp0DYMVi-U2aAcX9b;&(3y8Ev2Y4%~N zsYY~$vSuI!{a9S4;)c>llT%2M%kMOx_E1Mi!o5QC2O8{DD#@|j1hw=K4+L3tQ#I1! z<&K7B%(Kvg>cYjHViVz^*-^JTbFEuON=*z}L3Ym*$F}cYjRJ5HP2*XQ!%GzEaf+i* z5mQQcYj+f#pJ>1HI;n3bDzgc;C$;_^pYjx`JHFO1;Q;DYT-hV+tsyT`t_8e`d-1@)^DY6mxq>#(P_ZWR&r^%-O~1m z%)Ax~xOfBaav(I=LgG1_`O_ordq&#LGb6SVlDr0Qe|ERHHCVZYVy^s713GO zB?Q#G=m~||Q|Cfvc}rfyxF1{U_k@{zZYQ2*ml&UbxddhR)!-CD6_ucbJ3^=~A?2#n z4do57@}sg7#-|PK=cLnFXM~&&;SL3fGs0Yygq9h$zVE`YcPIV(o*CQ7Pn4A2+4#t` z*;DdpZ+J@;eR-PBs!CsOuXLi$x90pdoP+OpeBz4$5p3My?J^MJ$xl*ROJr)h`T@z?bw3MH|@YQgr@U0#tP+CB8KHI|0(#2fD5 zI$TF8rPO*YuV-%`GP-Dz0;%74PXc}D&wLqNtPUyP?Qf7pI-3c}5aak2V)TO1v^qd-T^-%vbi2^zWE2UI(+QB*VzuwMi_=fm=xpR81U~Ojoctum zmw?+zMeZ)=|AGR_uT3GeK|y77%^P*LNr|?(?Fnk$iP1l~=a^ z8x>QpFA3aX?Fzck$9hv*4P;h=6r>J3z+)E%XslSgB^|^y6AXy@Y`VifM*-Q z;r(F%o2Z?$urs!(NeJtYvnViiblIBiTz?4TX~^`+b6Xw+yBR{TQg~w%v0V_cyXjBn z8pECxb1RX&amKTy->>b=q5~q@(JeHHgo98aQ5YFfoU^eH&to0rqf;Q(ugAlK?gsKw zMs)R&TayPJvczmb;xWb}`~Y!UuIST^1~R^p4j`|~&Cp+)4uaBQrm|5qpTb04qr_i$ z`&PKVI8U&lPK;-dQ#Ol!4|2|gyhJ=lI0cjIVMQ4}B~=ZHV5W}W7Kvx&NJPW#VFM}@ z%fJ&Ia_+{^E1cLe+(%46|ISTvD7%nhR@ebHz$_^tYXELNi!4Y=E^36H11aWp;?!N@ zzJffo8sZ*G16le7Xn2vq=E$}Xoiwzt% zDIvQsA#W?8r!fr;yAS?gmBX-0L&2j78=?t5MSCW_mF!4qdw~or1WJk!jTgK>cD4V< z^~S5l^lNlik`ofEsCv)kw$6{xBx-*ZL)P=p8N?SEV46%au}lixObSj?Cmvv?VlwbI z&VJ`N&R(g*OX-%yo}9(ml*Kidb^js@9pvB>%NEeh7IMoLNzN8)%9fbRet3~BMUx{V zmLsQ|Bkz_2P0mqj%2A%nQN74fr^(e6%T-ZIR5(nQ=eP|$cYn?vfl4BC@Bvzb)dFv< z_Lzkz4Kdbqz8|?57kO`K@@>TO?R4`U-1420^9OxXFB+b(b*H+*^VxiX_8Gn?pQsyp z-6tv;1yI|sAdaFb+>s)*$<9yUEJ#c)L^c&B|AyV)iy5!DM!TF-X1Togpe}N&V0>Lf zvMqvLa*6keK*H)gO=yGgz+~18K8L)(|B(u)AB?c5)fIE9rp9tt`x~p3SW* zEHs=el7$hV9gmp|k}hz(4H^?!0@{w_I@m~xGV!;~l@acg5nWag(^i7TE6MaK8$0q1 zw(^Il6Wl;00ZQJe3IY@ZNgWt;u|zEE6NP5j>%oBF0)JA{Y6|gcO1)|UlzX*MO0`IH zwU{_rwp(Hgl`~yZQQu$@^(~Ux0~hpia4JtszyZlx2Ia@bqGvSOr_ET3XtH#4hm^*K+HFUT{c9}Hb#jz#^^Q1xi==HG$NZDljj>#hZ+K;K)weg z`!E78FerZqxRXKR3yv?zAemzTg-C(k>ouoaHaEC8H>EVUG&i@+H@9Clcha_Wi?{UZ zwe-8U45qXUH@A$=w~Sx5OwzVaiMLMcwa&P=&Y`IRbXRh|b*T*9kECr|L$~qt+BV(W zwo}@6o7?v1+YT<2F8(GE&&$8TvT zTxci0Y6lOu2QYyAd}{J(L6BV_(-5KU6YL*0#R1?BCW%fK{Z3YoPWIGJ&X!KDh0gm| zoji2tb?hzy{VpMoE|JtOv6e20g|3HJT~c)2G7{Z#`rYy#-O$u-rIv2xg>KcBb^u+E z%D)7k(IxyTdZBOiUoZ46wF{dQa_aY2WOfCEnUzwZm03NZ5Ppk={>tpWNCsI1onlqa zKrH*;5o4a$gQ<$uc_S$z_S*{szaz%}a}r&ZDn5cZKM9|4MwtWh)f1Jh#eSN9DtK0{ z`w8KA0|u5OzKNY!&Q=(IOnx5fR61X0GdB1}{M7nOgTs%xPod7`U)y}XG!l zkke{~xm2$9Mv*)*kvO;g)*r{OHy!3$y)l~cAaq^g!hUN!=V^*oxLfVcbcNcGiR7i@ z-b{`C{B-#568_C<2s5~_S6C+Y-mB4;#vdEw*-z3{Tbq9F%v4)`9c^ts{l3)Yj_T%* zDwsfQRO4=VHcMWfen5kPAH3RrU7UT}$VV^qp-`viR17|sL^mZoK9a%rHy8*MgD?gV zsG1oDLX?n}h<*z*cj5L|VeE1^8S?5kk{(`KvT6cZYYaME0b z@gOQU2um=!glCC@;wKqLKcO#Ni&d7{GLCzuYW^lxM=9w|yq-bfw?uuvtv5*H2y+ul z2BmK12&=lS^%R?4j*V1@Y4eRVm-WJp_qe$VQ?l~ZR6Y@AN9LQEaMq&DtT3VN&Fm<7 zPIKpsXyK?x!n-U{d9hC0TlpD5oZAIC2^QOh1(S*jW(j%Q+r<^VD9)XdnrVxj(uS%j zYH6sUQdFenI_GXhH>KrnWj|~2ZdHy@?{4+D{Jp)JDJ{#r+RPZL7sZ(8y7YDbs)VnD z;!1ZA2RtjcWLdLn`j-;E8n?GF1E=(y?Ze+C{6VKfS+jb^7uGm~SA^PDhkr_V8u8|! z`Y(3>DB)Mdw)M|m7f%oReouqHOL%O5-l$2&W?CuQ)ZQNMS(`rY(*=jmrEL0m=fhihU&~Mu zxSyT=TIg#CHMw(aSubBZvD0pS`~LZ#5}qZ~kzNB(eQ&%eIB|N0wP-w2@qU~bQur>VZcuM=-ub+ikJ?@KJ z=hQx5tsN#rPyQ_Q`CFU}nu~wEcj=Y;=Qi617g#ukJdIq^=>s|j>SJ(Z&7y2S&m}m_1yM;^c`CxWnvIq~L7y9fpO`#c)#9LB4QZ^y! z%DJ5A68?KT4k7*m!){xxP<5a>LFsVredmEEhd#W4mQNzP!}xJO^w>qWq8IuS2b5#w z-oClIymNq^CB?;FmGpa|?*$o003QG!dlst$5G36c{(GU%`F!yH=DUbT&kO(+@IbDz zAMekG*@QYks9qScoY8};_u`R_@}4xu8K7HCV@FIa*LcP%M#}zzT&S6^^r?u3h^Ingi|eUHUA*qkVi=L-lJT}$8+A_} zrpoI#-8Tz;w+!JJMFK#wiIBFN+idsrg*&Ih+x83Ze2F&zKAGtxw#-Z)u-IIg{lq`@ zu_7wG#Q5~_w9q(3knr>3x2!iMypTpgbg?>~rgpa1%|c&k#f6fBE~ZdX3OBUaL>xSa z!tbubXfJ(hIW#|iB2@q3OX<5{0{xX0%7*SnC<7tW!irdZ<2X^7L%8^t4K3lOsgW|r z+R!h1ZuQMetz|Ba;)~xCgj?2B%3VK&F8*w)Z#|GK_m~y`dO9uKc05w<`7`wEuZ#K* zfVOgh!W9TlmW)MiGlRv3JDB#NN=Z%K zYmDNq18S6fars=|IE9l2wd?HSs)xUEyHgLnn&w0Hajo+?Ck>k}BFye6>1NHvqn-(A zfo#n@7H-d*0N`J+hr}a@K=XxhE}XZSDt|W$=SR>2@=i zpKt_n)Lsje27lRt(WcCF-WRHhEZsF>yPO>kI;qOv+cncmnbS0=F5KES`RK(tN|5AF zzqz}hm2$bj^od_#$4sd}sd;u)3r-(jLT~k|dG2IXxb5`UzSr0JuNSx?A5ba>K2#n{ zxN0Jup|`h7BjzeiVcpCu=o!6U%L?Q}Lofg6VYvIBg+5u;qi8f3cxzEjj>+yei7$u= z+-Lw226e#YQm@w8`x-~>(WvHS%Z3!Haok_^IFq(@6Z)uWGIsPhSG;vg-MeWjTlHt5 zUhDS12^KZYbg7dfFJ;T7_xuCw;66mOuOT-G`)Q z38;3~`KN?mp&C2uX7dAM`Zh9v7~#l!-DDwVE$hN#=cDe|KQc&KZ*H?qrd%HvsI_iu zk6lbRU;q4F!mC}*&0n81jJ57Nk6kWaUZ1v;v>gPiU9Hff4sh}Q7zY3IJuF+aFirGZ zn4U%p)7{w$8Pb4eKkC1PX+p9aVOqx54gW@%&gexG$3hMujTHs7Fzxm42-EBpzlCYc zei4clR=@+-hf3_AO!Xju>d(2K70f;%&2A%ZXV26aLL;$RTZ@7yrre=!1QoaD6x{Z8ZzR2}6Wu^13p9pJ1@SDz?#*E0{nvG=168cb z$->ryQz7=Iqv;R*Pv{1#+10ZY!ttLY9Ly*4RkMZC25UH<6=|0$p&yEt)8z(TPPd0@ zxpb;bM-b?TqV-(;+ts?Xp}PC}P0o9R=!fFF#SdOsr?-ded0?HvLeiZnMxzq%xtpDK=PT?#_O|NPTYp6p z67=_5Tn<*-W62fT^c!tAhSP=9@3*=g?~GSJ?Q474?D&1*gEQ*RgEo(|A4?x&6+RfW zx}NTStxtdO!RzYs;K#?l53fFWqON{@{_2BC30d+5utFL92sq6${QIA?E(cJk&frSh zKT8Ob3UO>e2E#qsjo>VNCQNX)2qc<+s4K_{%zFm&*MGZ}n*ftxH%T;%5nWF-O$f#k)<#?ChvGxlyRm9o=rPX| zlWmr~6bp;O{8X>Gt%9rsn(e}jJSEPeVzmBHT+)V`+b*e^rr9a2SXZ(Hn96PMl(*mK zEXnF-wcyI`qEF$f>ftN4sveRj*sB@S+S#q0)I~GRQ+CDH_47Bf^w)%)y~Y(x^sMLk zTv2gz%?i!;=A%x_cdciW#ot@57MsgI{930y_<)IJWzzv7FF9x@yhC`e8LY4iXcY2we$>=f5$q5)O%oj9|FGaVtRK8rI|GZfcA7Qw zE)|-!O#k9EXVr%uRN0JKJAdJ|EIn+Et&!ZQ68MbBO z3CL4}#_w@J@AL271SD4ny;PA`M}5NOEk6bn#;$%SUMvG)&882Q0~lY&}2n`eL>C>%+^94-e23(lj4|FNO~(HR)sUvNsAP3nHxnay2_J632pi`G=Q@ z?Ip6QV&PrKIlxvt-3mWQ4n2;bi!`x`jX%RK4D^V-lPu;Sz{(X%u%HsROQM_dqe}o8 z=J*GiqwhgNgg(^r8U)hphC$fJ&%IE)JOs`qU2v$xir6qOf%y2Cr&_=rihTAS&e2GO zc8w85b$<`H8)@jPDJDptS%#v%KTU#ql1U>_@=Zn#iJ@=la@k-LM%18fTSFNVUAut95*AkB!U5H+sMz#lV^&$k zL5*axI0EtCv~7L&Jv=s*wrc^Mxm$POel z+=1__(@FSPs-JmU8WqKSO{QtEg()8(3bm2QVvY6r#$f47>Duk&{!^L#^!++9e`Kn+NU}YrsJG&tx)`!~GfFW`=tk zZAm7pXYnnz)868=CExbV6W8Joh0S2<8bh!cA<=NDGv9*umHzbD(r^XrETS!ze6g-3 z)ym}B60^I-x_yJw)n%VbtyOkzVcE#P#$FA>;8B+?la4UJMvsL{&81nTBLU<7)Cue| zs0=VL-C%&r9=ygIbri;&{F?Z+ZGkuxGblti>!~F}x8SC9gyn80nG4iEj;oTu0{NPL z1|+Abp)?;W?hwX;A ze$WnTKEXhvb8T!kk!huEjuo+X-%q0q8iMZ_`bbS}>2tG!z^$Ml`qXd1mRDF6J4m2p zgJFouj4=HnpyVME<1^;ar}lS}6@KKyOASJh4&n%&0JfH;$`}lx(S{)Ym8c+7^;K#M zeJE{3WrPBSG+rzNJG)N-w(5*9V66ZX5FZ87^Qf1Bg>*4|u~mXd0dp13ECQC8r!zQXfke~F| z1)CdDBQhBN4C^f+H-N_BOG9o}zEFOxYO3M_e#Uj>guoWxa(ytIj0F^mZ?X@Za*@*^^dzzt$Yj{5z+qFav{#*ax)>=#A{c>$@3C~krvQ=e(m&v^;}xP$ z7G3Q>&LRz1^g%L^pog245so5VB%`WzGfAbp6|v}FrRF~SIl`$f{nsAbYq=opbI^)% zXWAGD+qEe?+&=@8A_IcM&T4C)A&$@G*IlCI6&53^Gks6t8p`cv7|PYpN#xgm`iK>M zsZ~ej&LJ|8p?4f-=$*$JH20q8__jHy69p2a8&Y*319}8_UjjhVw5W;z)cmK#lcO%r zt4-lY=VRs9$JMA`JCUe2m^PoExA_URmRnvgR)hH&u&5UwgO||6F3@E?&tpiN(YQlu zM+(TH0p&6T`Wq8p;gY1}lKL$w<-m{TIU=2p(g)da$iI^!<2*KWB@Gb4HkSm#5xoK83Qq(b;4u^C>68`< zEVMZ$77NmzuccI4ZB*!f1j>MetU$pZ;X%_n;WL{-p9{igq2UW>;l@yHG-q3-;ca_x zhw$(bJEe<%q8e6Zgg->#7gkt|2Bj@PN1~1l3?hr<@wHX=rGn#--6Ax%d7w2Nf|5cM z7etl95rpSa#MIFQ9MMFg(Ucq_dvZk8Fj6ltVF$oNbKHFjY!eYcN!0N&HY#)<3RY?l z9q4;>4540rBexbv^NgM59V$b8H`igJn_A^L0Si{R;byp8WBB9QaQUP-Xk*+`L7d7! zoI>M6V<~)70X*{q{0Vt{+XIkkx5bZTFgq)F4-Vd82lG+HMl(`6wEOhgP^?Z%IUU@J zPQ*$3L=eZolQI)>VG|7=NTireyhV-l5k-2xKzg|%{X!^PE0p3glvw+e;wqGy8Pu&N zzzytRpN1G5X>4u|uqr$u0^^yI9MQMCwDND{@`UK86hJfiK!%UMEn7ek3JkD3h-;vZ zZ`z7$#*1$`Pido0ZNy9MYP_umz@3#RI^nmTG`T}`2--y^Ijlw4btn0W;70OSF~4VV+<{VuJZZlc^D2db5_kdF&S`I;Iu^6ug3 zEV99tq*7a7q_VqZaav??ai-qe&f-qaS_4{}9uv-Mq0jj`f40fqTn4)V@L4GVA4PNQ z1*Ev~@Lj-}5gnAia!^$ZD3Ji3H!0vS9)x>Nl9reYfW1Kvo(vG_FY)tU4W=9E<{7)? z8H(}LwQIU8kzr(LPT3H!Qd1Kp%c3u~$O;g=Oq5fsAy&y@P^l+S2lACW87(>+q4R=# z_}O7-Svuk(E0QKVN~|zOw=kTB5AD{Dn$h04we9rIgQBf6R3HJuvQJV|QJyFlNUSLV zrmW3AH9#wmZ)Mj1c}G=+&xrEznmOM|5=7p1G)rRxap;U@8b!jNi?;tPOc6KBa0P5CiR z$}L)q<#@6t-f? zlO!BSbK;$QRZ>r?q%W&w^lIeXYwo7hjK)@DPsOUqfOyyEyr=e#bmfpI*9 z+PyXyQXq8k-^+vN=K90k#tLf;jiI^jN%LlN+xC20 z^<~=@!H4_s)^;5yKlL|CU{GWQ!F~owoe;^94FN(A=i}qnG4WQQRr^G7JE)}{@2b65 z{sY-nx$~wm4jak-5@CWA-p3{4i}(U~G0-Ig$Rg2sSGkk?e=_Tl0DCcj`hAE-#|g^Y zK}=kokv_jcRVl&%o=#Q$Znc&!O^Kdo`aN16Jvym9x-C8W3q1x`J+NU>Hi_QH%Dt@m zy-aj~XJoxr3%zfzdTr?X>?Hae^!uDV`do$^)mr-07y48sz|5(AeiHow`u#y3{qWR& zL{m$D*h2rq$DL*#y)lFXv0hPu65vLOk-3(Ug@uvDtC1zT(XL@oq}1rT{^+L1=(hgo%EIXW)#w4; z*pbB8u|!Xp&DdG$*hS0O&xNt;t1-asaZJf^;=ivm;{g8M=;~jGvZ9u0g2Chu;~=)9 za-p|wAj|0gCy?dAKp-CJJ%N557@hAsdLaDDHPweg3^Na`bZVilSj$ntUV z{qUxr+fya4nj=(bZy?JzJ7wYMRO&*D_a(6^-3??Jc<(_3I-R;UoGu=zdiw^l%(X0! zK*v;f7e2V7qpS2ckY!KtgGltW>v;ETbEMjx8_4q8PI=@9)b-WXuZ>qe*qAqvC4K>V z_$AZ;^Gj7Rp*D_rK8=(nfyb;+E1!z$a3cGPHEke6Yc%_HLrX7jo%_H-!^TQi7(_(;ujkt-uYDGQofdoegL_U z=|G=+y54!$j*Vq?&<-RhLH9I-(ZDLN54(250$o-=hJ}Yp>_3@4e?ZW9)BzGGiKAitcIDx1!q_ABb?MVKuh6;RFLu zc1?O|Q0`x8V8)D(sg5Y$o-M=wqmX5O*~Pz!u8xXP`lUzXq8pU%h4A05W!*nPmI@Dk z{h52sn3n%KS@=kN<625*ExTJbz5@6=mtSA(*55#ueShX&s9$Fr<*4hU)rZuco}r%u zxdf5CYoXqML6*2;2k4708Q8z)UQxd1Ze0e)T8wRtY=9RH8NVUR_|M!6+Ra20?P=dkkUO|hzu&?bpb(Ztkq`Y7T^+ue zdr7)?D9C7CynHza2Cmp1hM6g6(|rCj_p*swf=ssGy5~6VvFmE|XYO?v=|h;q095Sf z1yt}F>4#F|{S8^>u&^mA?nWS(Uk_&Ewwf+HFY9=2=^BSwYwrXI^3zH7c|5uRZ z+8qjk{x||&TZw?7T%LbGmg{R3?MYku?9nQC?M(5CBNl%}S64-Xh+p1~O_5V0tXCYh zOa76116k4{0|nux@kFi!kfz2_x4E0SSN_A(5R@2ujUi`S%c?7EV9f7k?xj*7vy+5# zAn`Fs*R@N4>SpeBoCO&vU=CEEu)$TA#QszORZyCYNj}bzl)!RcnpG+fpg=>Gh03N% zACt>S@OALa5{CIzQ~ri5Ih&l`2J;@43`vCi1zB1bJxcFHzCvseYK#69hp(ic}#~=>!P9SLq$;y-4r9OOqx=5J6Ci zVouQYthL@VYoD3-J+nXT+0Q3FWrksx$$#$qx_=kLbkv9m#K8U2OjXLaQcJZG?z;nI zSk;Efb(8g#QSofk!qwiZgtpRt8qj@>&K<6(HO8meO7s0UWT__%ECw1g98x79n#qQH z^#4GXpZkU@E*@2I%c+OvH5>CHwwC|B;et@7dGF&UwQy^YjhBE#+Z({})9g^vog;Vx-F)60%eK7S7C zBlX$`#oK6gPSQG>V51d5Lc?oJ&rFC%Sy3Kxi)*B*XWbla#-?Ic(7pht`|E0zXbaU1 zn!dY4rpRpsk~Z;d;j9k(k;8qM~>%$%FLA|B7Bc;n+x-v5rhG$Z>`YO6g=1Ru>qpZTGp9kJFdp_m^ zb#^0a$?!uXxh>kOGAT!PVmU%a+ z_QieiM*+Rlo;D6yFdQciwQTXfYaF#5Jx)8N z+eUn791CVRc|+8C1zAppkDlZT(eG+JZJH`z_*S6OdIed|mW_TZb*0}k{?Ihn$MC&8 ztn~`ATo@VsUQ*nk8aaYOXH+L_09%3%0 z#@fEsKD|6Jef?wgR1$Og?(Q$?=EbRIjMs&4Id*n&HO_eVsJ{8Enj ziG?hGQ2PABLY6u{!OS)cie;~`7X8}s!Kvj-Fb*jL}!ht%vfw~QW`tyO>XMuX)plK7!`Z|AeaDaty z0G6e)o)5A-3$g+S+i(QGqzZNs4tCWIMm7YyEBgFQuri@SJ>dv4;y?uoUs;x+NhmZ5 z74Zg%ifTYba)d;k(#}G%sX}w-gEK8db5Nmq$k5WH(6WZm zw^U(8uFQ|%fLdhOqo}Z^hOn0Tu(q?Xb}DqIFuGe8-HSx`C!q%$(8KfS|38rBSE~OL zkV-N@IEdkYu^9aCC&;#Y?$}(I9VyimPUI-Yav2WR6ipRik;-)C+TxiK-D<04{YNpZ zXuVwL4pJ8zz1vTwiVcVA$`fbImBac5B()7ib@KAIhsISTKm~;h#f>mX) z_q9?USBt^Ms*giS_pDcj8>=lEf_~BzBkO8(s|tc&#<9TZ#F2iZqEA3}^aFl+Wm5{?oTv^$={C1tvtxV zGukuCX#8<{xH0tR!MnE0A7`I&zwS{+$;{v+)rE=8lI)D$#>q5)tZ>kNZ;aKva#q9t z2;Gg(d))3HiYPa;RQF~I;#fzqCj3s2SyEC#l{(+tN|%jWX5A~531xJju8I%~!GmX* z(2ba=M21MzK;2k*{Z|mFCzCm$8uFYUAlUJCw3{1Se>*Ja|p0*cgP| zT!Bz>+Sr4|S0+-t#O$vGndkT2ZFNg}@{q*0!hY~D{7Y~kV)i|WkBPgkP*gT;>sl~< zP~>PG z>C!X`j?ow!`(hiB;2Y{e)P6Z2DRK%~wErQ`S4%+Mz6y`{D7ZTiVL7uNA&x|GWS zIPbnDUljL?f^>GN6r@6=B)kkWwao8c@EOVs86ick9#n|CF1wc>mm}hw6-L&U^VUz- z4u@}_{6)g6>)qH)czF>P2$^QTu|(By!?)Z36wyc0yJt*iLQlpgP-6MaDX0VSa|_V{ z7au^Rz&%H}@`aBV#G4_q{l@}dKf?t${U3(0wTi<=rBe=7)bEx)$*Hi#4VUH-X-?wH z_up!%G#H%D^h0;j(V7n2rY-j`-T^=||0Kx9*?x$m<38PdbvJHnd;x+1qJ@ZJZk@L5J2B;}alh6I0ue2sJi zn_aiGo=0FvvxEZCQg&^-aYP#qEkd`CUgAz%6K~3a^ku+IxTITSkR-Z0G~s0{v%?xcm<7=qC*1f@X0t)$015A2K}x zTp+{!EH|R9ekaI=j4nv;W1Nfu7vu6%t_6y%g?CLbX#d+s zPY+Akq-@sGcSy`=mXEgXQV#wh;d_XtLeJ?$=yKKYQ*O*|&1WopbYN&8@;TVbqSX`u zTQmFjmCaKCUgp|_mb)>CTX7D$u~stSvMC0|fK`}*tFOn}*`yoRGPQ4a=N%TtN(q`* zWKe8UKqr?FDZc$E{cpv(S1;)lvfBYB*|yknGQfG^VU0@iaPBI;Z?4praVaK7ydJIU zM!8nvw@d=LOKZx)kS|Z1m>$E6q<)#pxocN4{irFB>R+SpVPGvcVg(Df`QaXx8rN5jkxJJMw&LpA@YJJ9xeX?lLPv0mY#VbCJDn=9% z!WzTBx9nVz@CLni$eoxUi(=k69+2wFO$qq+8tk0yZoC$(wJC*|}_&gLy5) z$K4h?nyM=j>H8&%Jfh5z7Bm<8>i3DOWYzWRnDD}b%|Yvs(W=vK4)w}Vo&AD4wGPaL zozn7qLmUyn)hBh=iV*Tv24or9^`G7F-U-qH9wPgh928|)q_0Opq=J~x{blNF9a9h~ z9aU)w#jjgy5i5k7aTE6(ZTV2*Qj(`dn%rQw>x1jRNqGB+V~=@ki*sEJ(|s-Txf9hQ zWaIU>E77+E*PNw&r={(72@piBQxL!IHOGaKJyCO7fOx}r2NkqEn8aYrx22P=Kj~U` zKGg$1yk{YO23@4dCC1*;RNcwkVCmfsbCJc1*((x$mk&5u%S%cWGHxXzYKp1lyrC6U zhPaqF^E>b13$o$@T2xQ&e=)n4b@UU+ENk^ z;r_HC`SSq!$X}%0beLBzg4YnhsJ<@xYD^ja#=MM)+Eh-upZ_|7_0&pz%D?Flt>E~) z;cGWRE^oQ6h{SBxQ?+KEPH>ojl(Onxkl5v}Mr6~pR27G#Ti_F7>Y?BuLmOA0oZECA zepN4sU@A5`gIzZibNO-y@OC+#i@vs&(jd79iuS+0#DJ2t;es=F`BRIMq3%_wM(C18 zFy&;93^jTZ-*o~%Cu#HmeL0V6IhD0gKAt18v~4#-0}LH zIDI|f;%$Yb%*u7sDZGa_D&pIW&iWqw`kIP0J6aRZc?7#mIywb}33F(#p zmk+^@*!RO+xp7x0y-Ho_`)bR7J_N0MO}cu+D0#6D!R4X)@(=HPt`RY)HdL&2p~!9- z4L4N&BO&&055euROqnnSwWgZgsltaPMk7tNdvg`0Lm9T`L+RWG^Zjm|1$RFG*N5Q0 zpAx+KWc0UD`nOT~w^90kpHUi{RwSDzzVV-o(rpPn&Y(>ZS*XcE7k$ck@L%0PH~%?O zl@}V+MDdX&kKw)Gzpp$QmEfGquEwdX!!?7?$}T=Q zc*mna1Iq|y(bN_yN)&5#P><&mP8Vr-6>I1G7o#-3l3D!25$+`E#eGwkldF*`wj1bi z*!oEsU-+KdN2{r(&R1`dwYLnv6wkGM{Z{TlT>2D$ajsL{79J9n&eCY65x8ludT3B= z7OVVzK)nu(@YFuM%`rMCoohs>pv*wJP_C zk%eUCt2sqRkzASk)X<+(0O=xIx2-Cnv59Qx1KG_jH?P7pB1S<@$k@_cf-xRDwJwmTvFKEca z%Sxwc?4I&<-WK!NNn*=jj$AlP1i!i^ia$SD zsBhD4t4jENO3*lD{HrQy!y4sZlaMlh9d+YWV|J4G8iu*9-B9K_8X7z)LPnog9Ynui z<~*LARijZM^j&o)wCQY|Tb)0Q^75JNy@iSJ$IiKo+%FUjnkJK8ZdjTA6y3XUWpe1> zVqn#))C8MRguuJG@xGddg%{8r3LheWwTCGiQ_NPq{VIgf2r&|RMlI+46$^oeKGS9) zTgj`ciWc264?cfC8X;Iy!2Q|k;g5w`3OzH!)ppc|l z6m`a;R#UK+@UK$6rXBuX77ovFS^N$yZcw*X+nncf3FO z%D=uX##qNuJgRMzDzRwsKx|>|?f1Is%g-4c*gS;m-i-76XF`EAc#nQUMAKH1qUW22hvuY}M(Hog?Mo_mtn zaXLTO%MQj>o!d?be>pK{XSo{*Yp+xEwRX9d|5$mLcKYtr>%jlP&ox@cvvItDuQy0S zl=0fhh>v91lFB_1x-WZYSo?qdY6nInw-|nSH;*YXEz7{m@!xvQ5ftrfYldI{db{Dk z{LKV2A)ivirQGAgkB#pMPakyC%b6q3EX+20)N6QrLM>i>Pken)VEi!?Y&JNJkLGpBnew^z3&Hc1a5E2@dus41P5q>~j|E*B?xw zV@_)Vj_h*a`2&n%q@bk!j*aHT@Gih)hVwq6pQqj*HI37B z!Qta}hwbna0Bl0TU9s26*+u_564c1 z=h4Gw=uxWhapCYu-S9Ls6cr8_vH{v+28R7HM03!uEBv9)k+X2kBbTr*`GGqCXX2UH zVU(KEU}xuEcMV+#QFO%XiXibqf5LnxvUs-_V9gph^rNu{9<}DLd5yQH$Us*U_;YXM zR&W3^nj$&c7Zpug6z#tgO?MtmFA~EtAR6LB67t&&4FfYoP?1ERfsTY2wk$&AA43E% zr!O~jpbC~|S&Hn^XBI2Yuj>lH@)Xc@*+3AE4^aF1HIhsjWiE8R|>auOR!2#uxU(q`96XC9+-mH05;{fA`B*EupM zI2roLO^ymmsS6FC)7YwqNMiK^Ws@*Y&56~do%W?sr)oK0-~wHqB(+1IU?>kqN1YkHPQLg7dWdAoe9!-M0myYya~-?ea_Xk zguS;xXajH@ArP`}7PsRQx26qcScvOtc)iRT@ZntTQIWc?lKQ!VlL|-v22R3(61!O= z`bW_l7ySaHdx3jOfoD@eY>h2U$_n1)wbP0ilFobH?t)@vkz`jJwD!O!g%7mu)KjV`K_=|Qsp~Q@g_|f zpLf-6EEM4G6l!&lc!x(36B|37`y7#mbJr=fv?pD>X zm)x~Z!J-C=izP}PHOh-Msy}KvKhSq?}LL>jY6mKuwB5FV_35Ej z2TrCRu}mShhT5RnoG4HATA~4jx04-`vsltPlX_*8-a)r=L%QykcJl4(;7KJKPJvQ; z^;7*zFZ1Bfqtu>_+e+nG%X?~{RKSlsy0-;7NPl*#)AeYG_h>!ok!FPlW!}YEB`nS3 zdHPEHMZT8_Y3DVUPTS|igdIudPb_&=yIwB!+KYENJn6E#-RGj(=Y-Ac!U_0|bbLaGyJepD#HjYfi4P<^8A$RRxSy+NISaS011$%u ze5mWK4TR^4+ao>uUZo9|whX>a>#HaotXvwbrV~j^D+PU1eAX!$+0qT37>ZKWOGz8( z79Z|?GTiTpO^AiRo#}w_zQhuo1xt_f#YX}j56#~mT2LKY{5iOcl}hPGS5$L)oeW9V z9uf+egDZ#k(}oY0Mh}0Eex)1RpBlcuq?j$=s~iSj6(9YzGzPdF!Q~#sQ5(N@IR=st z`{+6Lhu~bi$^L2ad4LbRQDlrtZH&5gf@XPw?s7ulq4?@=ffJstVKdw5YfJ=oAo3pX zBcPuGN=$(wS9mWI^Wuso_jfODPd*PU1%yr2mN!7K3Mc)- zB92e&wBHA?_bOooH^ln`(ZMQl#wy9LRZ{4cQA(x0MkBdK=e<@o)1r{iDc#TeNB?TP+XP{>S;6>CM4#MOSPwDf!xen6Dj#)LzZkvhMts zMJsl`=Fd*!R?lxVRd^iqulbrtFwk z>Ii>{X_61uRxFwr5A;~zq-#1&_Z-^Bf6Y~P8rZq_VeH*I37cbGWia-6?(l+^+SD|$^zeNc9$jH|T zsn;z!!;GQiEG8m>vV-G{$>Y*0tVp@+7wnT#Cw*)w>WvB!dhFUz(G@uV&OfP+(71bZ z!el7MG5)!&ho@O&N)d2r;k<`#6eSzlEv5SVeBU|u)YwH1&a|1o;8Yis{X^T_yLls$ zr)rR^`h*nCJMum|n4vba=-_rqn$|(+b$W7QaVOjxCUI~<>G3xBuaX?;6Y8ILYe2QQ zJngfxNnBDjiu3(->*jy7O$W#FT*Z}s7P4#*0~%HOwSNl-@F?;%|72*_PTZ98P3m7# z`+Q~Nq(>f^qB8^m6XTQ0RXc}YGlRoEw z>*l-W&?fx+!d$j<(N_eCKqabCsl=ZKO40+JN*V!5LYSZB&IcViu2O*k$6vZ%mk!z| zKKV!YwwrqFsudI4^Nr~9EhKW8?goWKd``G}D`@-SF55^>#_Mq#Vs}L1uazDR-q!tx z2I{)sHd{^)etc;^{xv*T-D2>)Ipv03Sj?EQS(Jy<&FK@V1A=CFNA!NQC>pF#Si|Bd zR>PHmtUjoKQL;|@*Bin|HF@uCmXm5)4XRUX1iT%8rRs5h)@53hYiZwG%A=%b>^N(!h-j5=fvu!G4kizZZps5wiG^MClK zpKV8(SV$31)w?T~5}Hje^6egtl4j_U z)T(yfDfva_hAs?}{YTp*;pxf=VvNxdyw6|YM$6n^Op_b6%PcFugUxko6_f=A5PE(h zdD9Iit^Brsh(`VH-d4;_3!&*9Ph%Yd-jDd1MS531{Te1p)KX54BzjeQFGVn3S+-Li zPWtg752rwqQhs#B@5rJ`OeTuv>y!-3Y&N`kg<^pAx;=s0;{JDbW(pQo zhh!U*0Ekipe#d92LpZ*S)xgM0_v2IxPkcB2wP5M+{p-G&cy7))(f3Z! zI$>fuh~T*!}?x8ko5MW3%l3{ zQYmRQY}fV4r!#Urly4D!B7WeYMDj(GM*0Po>cB2qpFQiAEjNL9^7>-Mx*=VfNHISM+%ikr3qRbZ?&yVY6R1NoD8X@bmu$_}O&^3qKRi{R@8n)%RcE z=ff%o;M7p%+0E4Zm+cvU!Ox2A$0kb7s#Wi;ATiZJY~Ajz)k+ONd5FxSy67*Gg2mg{ z(As#}PYmDVpT?M_;r=r{FI_X!t^a9}UMkZ$U9i2J*fwglo@xT({&R!z;*1%N2^5A| z@H~6+_;INL)t|DOwY44vO3=3uIvsvjEy-tG_4oXwT8?YggkBg2r@n6 zLD|ziqx+Txdv?bBWP#S?)G-GOKmSS3zY`*HRtiw&lrlXy>};MkCKi2U&_(^Eip`F< zlTPZV343s%5;JguPT?m7X58#PN5o{DI+`UD_z(CQ{d}c;TJCO!Y>e@fRWF(txE0$0 z@oY7NYRfkia(IGqU$IYYVIsmc*Ps4Z)W-cK-VnP$Z0o~*`0e^XUq!3rdcWytPOYGS z($SGIflCxO(44YneAx87je=;l2u9X5K#R#RhlDE;sr)FvDih4DE&NyJhkGs_BElZi zeitoBYbxUm>JUO_5p(Y*F!G4VzPf$y4Tr75{dL6%F~?hx9w%zDSLykMll-?!V7Z65 zK;w%zuOdxj)+zfg-a=XA5??R-O!Q=aRaY#ANxq&`G+K*>JYKi8_^|^vJ@1ZHqqyHB z^$*1GH^dgcND2$5agmSon`T>j)=i7)nD&%>NdHN3OX7{Ff*0$0o)L+O>N(YH=kN6V z{zp;Aw+ScO(P)0fAk{9`?2Ig8dG3(-rWv&R)-nCBc{6y&QAB}@R_y5NEFwASQL)ub zZMRnGpY;3!g_S+aw({_TI_+R!BJK0A2W6n3`s@G$@5~$UQ{ORX4%Y}wQZWApMGu{&0V%9IrNQlKK_S9L*5hI7YIt~E}EEQ|UxAm^qfky1Dqia{L;AbXirR)b* z_*FQ7fYgcNxn<`4_`BVbUuz!ugSJ^zv;)nV9-qOulJr4 z?rMJx28xExEL>=w<>wk4P-UkccSJI7uT2-KzhY>?=|kFt-Sif&y2teueB-Vj%Z68# zASE_E|Knn=bh-$^%QATC_&x{e8(c!aD|Rt|SY_J&MYB0x$qRkK;PAM%E{9px{r0?e z3bI0^TRRMdX)HdaJqemCJJ7)Sv3wX&-=a3pMikBYgkuZrebXyQ*yHqTEqqNn>t$4%+!W;wtm(>cHmZuo5#-$CfNB=fCOdCn+DcHOkjB; z=vgR);FYtb+BK@&^pCa^$Ba2!pfuWpL+@EP?Rn(kt?|tWLIck6{X5Rh@#-AWzcW9g zTUTf`GYK2lV+YzvebL>PJha88BW+Vu5VaiXxf>}1 zuR^cWh{JouKWfyJx#Mkv{ZCv2oit#E_s-93_k^GL=jNn89XSv(7#-gXKRzOo)VS7R zA0c17#w`2XGxgyp^6<_iIV@*mtur9PYw|f`QO9pJ%4?#<3_f7X`+=7F8r7)HtREoJ zy}ypZ^SS3o+q`Ee%x7`NGq@Gw)C5`K_W-)5%noVu+5OY=iWruNVY6`4>Mto1trv4+ zzrMTLq&fP&Yj`plD9}zBj-R|Q`q=~cb5eX{krX`nO{SH{3z%dN%!j=lcS6X*?!OQkdFC z;ln397uO;7crc))&k~Q}-Z(+g7J*YB#3!E2of0<9PvVCluA_i`>~odDR@U9TenU6# zF)~mW=?D;h&FPEJ=W64~s~4r99V-Mh%zT=}{$TJMY`(%DXbG7{5clyA0Z~>fJhCT; z58x=SfK}+$7F6!kxXkrsdE(1z7Lqxp!0LJw%{+%5<_r}@6{3seKn0U_7Ch@wfcI@9 z`e$E!*Kx7$;qGQqm|hLaZ}1Omx3y<|B-cQeU~i@-4B3owpuI^xCPf@794N;ea+RJ( zc`&ox;m*Q0b+tLn9|o{rm8(37opC0k@@J`$2<289)Jm$85c5zsAT=yW?6 zX$+Mw04d(&BKLiPK@czZu-Q$a4*Pv0zlWXZMt_1O>hQ=5fd{KcgipB=Z89kw9qKHTUf(k|TYu#(Ndl7Tj%czZKc$Lc5s7*97|E_^ zv&uqdq-cNTZ+F;y@qcVC2s8&j=+2G0kJ*HdX%LAC6ZKU<3qC?4nUwRhK_3*Memv|6 zOfM;N5)I13x{OI`sf3V+K!h2HWH-7=KJW<^etw^1_zF2K zMY2jt)~$$^h<`mGEIPjRdeqW|MGj&OcGr-Dxdhl1@ez6hVFz&7mO0cnifvn%i$n_K z1P5R}02g>TN-tjLMf~IKq;~4G&U3Y>7mRw+aRyGLSX=kSs-Hlo3K@(2ZMUdrvM%a3Uq#*oKPNLva-{wi zGv_NnIg|Dr8N?m!w}Rank3&{hp(mNJ0|0ceo$$SIa?LuZ#27N)E>#-^w!24ycm0+| z5xkxNuwRs_%^BIEm(8a5<_dmZ(0KYR~UV% z-@zi;U#LTG{f3|Y%}7w2r11nn{b!0_s*^{k#1n7ww4UFr)=On1%$DNHzP^^qDw?lT zoNMl$Z zspQE|kog#s5@a)#RYwgl`jm$Q$oF+GN=hmERGr#&zs%{<5UoXj>h1HoE^ zXetK40W!r!6*MJPgpSfjxrx+yd;I3w7q11FGcBs~?z0xfDi<}Krvz;jR;QE>HkJBK zW#5~~AEAyKKY!NE<}w{ZHt<65x^J=DU@_uTvDaYfy6D?YWdkh#t)@>FK=?F>FcwAQ zgyAGN;uJKQ%#xZe?h-AEZ;I&EfBWmA93b|PnvmS|yvUron7=q7H#7%pf3v^mr|BsB zP+UMhRJ=MEfs#$*SyCN|FR~<#n-|QmZ%&53wv^jj}FV!ii|dLP;bKe2`YgN7iF22^T8XmbO4u_59|LlkXej96owL1ThPV^V5kN^@h{ zVq?aS#!T9#Y_XRMSYpRi z@z%XskJ|q?{Jd9bYn;rKiXIEnOMBmPWq-FvV^;I7>~HLk6u2zb{@(IvyRyHJTlT|R zKmTYupnZ2J_U@~}yOUcCBDcz(DwJK@uKwK2+*krbh~kyn*OYMK_45CvelxXvr|mes z)j=-aLGh%6%CmzetwTM!_H)x4Q((CTuE)GlnH=(I_=9B$Jl5)}6ze4`E-8iF`9NWmfb4kl!@~@gyYu z=HmSps{KJv`ca1SQH~3<7GERpnmMgKp{1 z6(7uhGFa$2Se!PIiuuGnnk(H&9)t@8lbfcT%e_;S=qwiJ0s{(3u zs~}&dG4BfK1sqI1M0k-&>=y+Ns2Bx|jN(X);~I|Rd5r_p$HA@Rgv;ZQ%W)|E1YBZ* z)Nq2_Yl0$ug6gkT4+H%qlf)#8;UufqBzyWKN9!cl^5pHy$vgB@JQ7oUnTAvRUQ>eU zQ^KuNqRUg_t&=arDb;x3GRt`K^Z@xXJhe=`knw5t%V`a4@tDMnw&BcUuNmF+8U5B7 z9W}5YR`$3(=F2nd&C@r0I3$ZOjzxfici%`)}&|Z|eJR>ihp@>ia*%GJXd+kl2s~{I>zVi8a9KPzq!1 zzO8=rpL`TP{YCJLqHE|-B^T8Ab6PWV7&Q35st-vj{O z!#$m6+<-(Fd%#Tr7OigoWb7grxmKJ-E2FSU7*m#sGG&C;*DrFjXOj1& zvwGZ5+ulFgii`U{zA5{UOTd2-T0wUEHCbd1&*VZ$dG&{pl{fk#X=SYFb~VkEVwf~j zRNboBh7xa?7N_lMnX9GnI4lgiRo@(YBj*2!?$bj{&1^(8jhcH6=Tw184p;goZL3Gc z8kPDZ?zOk($_=`#==UDk=v5g{q+q*RxfkoLH;U8u9=|kfvOikDmbTvg@Xq7f9{s+K zy-BAJ2`x~3+oUrvlA0z>oF}4PDB+s1cg}mqG$E&TPpE`GzvWc!gG8gUrdOZl3Qenn zBn;}T7i;Y1R?3=P4?Z;b9|I+yG}vu)M^Zk0+v4_hyFce%u%uy=)82Hs&WE?H9^b#r zcR67|_l;Uyk2l8>pO&|IU7YT0R0rRG+UD`&s&8=WParyC|vTf|YbzDE&A)Qp>OuqUiPyUn$=9(uQ!!!(R% zRv7DOtyr2yh_CN#MBM+xVHSy?5WzZH3@^-LR9SX6V;*pEK9ALSEb=@~+vvq}T^!-r zc@(o&P;ziuxZYYaO@8uP3RPVQd#YViQGS~JDD`%_%a-1DhV%E3n{QrU6cuFpKxuZe zg4p$UvI7J|IdURJiwkq3)M>D=xOoa)u&E0byHs@G#YY)~XD_UZ3ayH)5=t|gux3_1 zhGws%s?*B4tYNZvue4>QsieGhi)O#P6W7|NvY)hMzd|#7Vchf$bb8l3-S+WrZJc=+ zXWgPzNm2ch_u}V<^#t03#?^e4J577V)^D5l+ZGR6zRuEqX+7Lhx!d;R)8bOll)M-` zWR!veL~ZNr0Hm-`lmd1^Wq#UsktwKFc2hkq#fsoM`wqSI{y&d;Zv?AW^>e%_#fspG z`;LR$T|d7L-s@AX9v1#kT0J5@x9>D6aqaSWR0gD0Gp@*3Rx_bW`Pq3=P3H1s@}YuS z?X>RGvf7y^I-g(78v0*;n>7tqtDCocQ&#uhCh@b&f^FC3_XVduwfZIGhqC%*&pFIz z*A=g8zfM>DEh>RjcDcT~A;$7z-0gsyXT8+MX1qWOJ;e$9trVv=x2-p>(57vy2;Q_) z82r|Kx2U@9VizldH}BPqy=>lZ=zHt&x$(H|$LDva(3UUVFuRt+eo(pR(ZIcTKaa+Q zV6Df~5A0e`<`uAo8tH#lH+q;BhGymCc_%r(HF18f2@@Dk*;4ETvz4$7R`3sg1SS$Z8D*% zn~mFUVCczr$HX=WJ5O-Y(6e8YNu9FnypaRLW`a)1qiA;if}&w7$*GjdZgzq4fe{-~ zdp}b|o`Xn)%oC1GI(M_P~I9XKco4O@a8g5X_j@*72KP5X<|Z z!7)GFxJ-~hA>w-Icp&>p7Bt*a=H}pdsB~O5Wo@CXK?I!HK*Q0Wu%M#;D<@q)m!3r&18Bl$vuw@`PFO~X*`-(v~uj)&d56n-S=ZWvSf;@tH z)bnypF5r({Qw_E>>gp;3zu4H``fxFM+IH0Q#q)RwTOBjkc8P!Z`rEBlT(Yxv9Qi{Z zvL*c`$L)Lj#1oGdlHmoaUk4t;PXW@2a?T)sGxH z)PuSiRq<*%kCx>HOYlkJfhtYpd_IqQE%T4UrdrGe#u_jq_TJTL@5L>Irp`xQMxD}* zS}Y{y8pnKoRYUT4CUY}&R6nuZ^e8wE;x#)T)b~C~U{l*wG@zMG$~egrNZ(aN4i2*-pH&&=2cpulYtrQ0o1 z9KjiA&i7(k-`Oopj;8_|mU$hrRhD)(W@S}vCy3NeJU;yL7H!MAs*W-Iq#M~ZTaxkW zt=;9Pr(fJPBc;Nv>8|;eyi^~1Dv_-Fnv?j=ni8FCL!Z9(68#)fj)m`Kux!OT4S6}f z!ZsXj#Iybp^`LRxuV zX^_2i6xrm#t1Ks%o1(%DSF}I?0R<>OV?P1U-W_G3ss(UR z_aeaZtSoSEVGQ_lCP)gtPU1TbOsscH(D6wY@&Q}=;30holYFpv77>*%l*4z30>Ebh z>0olgVN%lKA*YA~KK-g2((5ERYgC_QyLS&H;|du$hLBo7od9aa<3z2-1l={j0DIyS z1az)~D8UExf(M9WOwf!V-r|AH!@=YA0b_FkBYgo6b^<1Z0yPu^wXl8`GEko?Fu9r# z=M)?Xw=Y20m-NwE*JHyJUgWb>4ggo7341#{hdluFqF$QJLdFgOB^4s_G6O|w5Z$+w zezA%zo0SQuAnM2j;e729Kw#; ziE~y#=1h1P0Q8Av;J2i}(}uwB4UuR4kr(rkfb&S)PL_Q9if&=f$+o~grzT9y!NiuIfYg;(!Z}zN54p!f;2=wUgn;pi z#2#i6V{g|iFd8z%=Y)i8;NwG>oCcl@51urDa1RbkK*Y%+@RT!Qn-!rN??bhULLUx< zYP-chPL9_Mi8m;UfAl`yfuAtrrjwaSf;W$}FHeG%QbJ0UykG(uV}BU?bKiJh_qX}( zl9Y52`H~%Qhmv-QT7+{KT(aIieAS+)91%Vz3^U{rMYel{M5%fsh_PF>)po4^iwB4T zhX4RFQQ%+HNF1byR^~O5ux?(#DjxMJW}@DOK-N#IFZUZ@cx4N2T(JrjAFM zZM)$p5JwtI8b{G}pn{G7Pa3fcKJPZ`1%$!USe{1PKHH=}cmbF#$+9Gf2M>WtADKpNYn}7lwT* z3{NSHz9_sw4xrX`DF?{vyZhoqDVW)lS%@a$N)f)BcS64KtLV`BbmJwq1>mizbEPRr z)&ly$iFmewpsX4?GoO@l1I(Xfbw>b*LBPa{VVEeOdU391ajDi|>1ayncvI=bLg~~+ z=_J=fmg|rX5h6PcQEV4L2Q_4Y{l1=Y+KoqkxRpRqw7uPF+RG#|LxjT2E_AL@#uX{^ zz7e`eAioKZMf=EXF~zPc5c2Ol?j;Ips11>oUdWt9!4Riy9@OTGd;%YCj-+r@*3X_H{e=*YNT?bm1cd zQsW`{cX5FBW--Xb7}9N-+q*GlCxpDa5S~qttCe&@6H!R!W6&);g0M=mANAg}4U}RH z?-*Q7 z1NyK5ngzoGGDTNcvs*+7_>n-7+6cKHFm>A6Cb8OPYys?#<~G_EZLyYCl@^6rw`m@t z)D2Jv9Ae0YudhrTQXx`aFKKKK+**jnUvq(JW=)8YSz~+(Ua|%|E6SXHlD4z=IA{;H zA+I<>l*fO9@NrhKo_N3F18grznFH@`#uc=wC8{Zg#`r$IYFwcSx3n9Jl88wCte zO2~zSBAJ}T&lPmxAXeH$(Sodkg{=OLk~JAnsXJs?hPc`av`GdySk0Do4Hx~9eWdIk z*Zxq(pyooOMHtf3d#gocxJz`YON_1?A>MsoRkBsXB@W?I|H36s!(|n9e@&Z?o?z*>oT>~Vq_hEvga)i*&5)@Et@lmeYrLTh`NwL_Lf z0)oaGi(xwh5U97OcYkXmFrv9o9MYCBkVzpLqXw_M9m zH)&{(jmG{ z?Z8Z{SdNn3L%I*pAd z@bVK9x)bB6DKyBrv(W;!nBXy;FsPagFr`3TKQ3tu z@O$5v^+^=I|A~rCrH+rJ4Jt$wrRYFj52dnDv}}O}_(09dCb*t4rx2**=mBV8whrly{a4VQP4$zge@9x(oT>qM^8zK zoNw5683%006L;9Z#E~TkBY??nXKyi0^MzK6$tBm)0rm=EQ<+l|tqaA=3*w&_Bu5s? zB^E1*7e~kdDdT0m)xa;7l6pQ~2Lcm|Kc*9Yyp`ew9VtJHv>&}E@^0Fv00$0sGS-Qu z?gIe&Fsl&1@j3V9WjD;_^8eG@U4B*dx9b95bVxTy3kV1Z06I4bjv0ph%Bdq%T9AQtCPud%_Ch9BkzITOadnPhwW#iVHTyiGJ`~vlTAKz% zDutpBL-V;&NgSxu7AAgD)GE8@k`)tKa;EuMEzYz`wOLgQUuCn|Q1{r-Xx-3R+E7VCS>7SseUmV0Z%xG(Hi6&Dn}z~&?-Ep87s1&Q zyWe!6-f|S$a!TKd3jl6$tz;UlpyN-H?-Q9v#&{N^3Bg4e>M9o+QN+;(T&_8Hvvx7`jd-;R#l zj>B$$9NbRP+)ZTMO&i?Jz}oKSmhYe=cdM|wwS&7&n)~&P`z?d}?Y8@0%lC&P_s6jN zlY{$n8rW$D?7{$c*#-k}hkkzOjy0Ij#1D~w4>=Bmx+OC7&y+UPaf?X~s@T$C;~ zJKpS{eS2*OZFIRMYwEQkWfij}_ofI4n=cpfVu$sBE-N>O{P^7VZ?zpDoUG=GVAfr&f&$Lg3oVW}n|Hb{cB@UFk%WanAK$Nkj}tJv*K>9F^{qs_ zO-Rtqad)xCZR`HcCzs>-@4mvqLhc_=_a>XnV7hFxh3ejOq!fVsiF~E37btct2yyh~ z1%OAO(RKu4uw4f-jRR*V>QfWAKE&!5*@?v!8!3)0u)qn%le#Sj<0=u2brY#DL`e|q z>TyYunSH8|B(;ql>!p039VJEOw!kG#8+cnGO%p*h-p>%l5G}))qW40UIqy@Y?9;N? z@j}WamriB;soPD>I^3O+zehl%9F~lhF&g-e=-)FjJwHB8){wONlOgRt~UPE#a z6kT}?R|Sz6d0;By#1alFQjh&lm1W4&PRC@QC($;H<9(@Zmf&8i4atkE z)iEvmOrmR1Q}a^Sw(hQ0*RGGaPS0ipoAizSBro3^=V|x4H!jakl?ty97q|_|FWEvT z)nI$69BM57?~_(W+%rbmYT_IWil8xKh!KT-coD3UR z^SrD8SC~anPH*F{;)2b&IsUB5sv=O^F>AQ?cM{f(7&%in>$=H+$L|{E!&CMfmRp-_ zn)f!{Y}?LILAD(?IH`vnFrj9&mVA{I=%jr*zCp7Fba(Xs=dy5*A=p}DN9Ixf?^&e3%82ra;9!>h6)<5ZTD8(zw zO-lKCWje7yESi_8%oRr6p?KV?-SsM25)qWbPLwQ_CjBwAYSG{Z)f|}wHiOz!mMYVs z6wVLh-3@Aa3K<~Jb4u1~$Y_>G41q+WdVxxwe8$T()*6e6BIPR8o<@x#jWWG9C#q+) zRx?$GKcXd?G)uJWtd?ujp4HhbG})bu_cXmOd(-x5j|6@nhbe&fOO%SuT3kkbVKjW{ zYz_A7-y*rydYiSY-u-X8u+CaJ6N54CwGK;8yp~PUMuL98>_(zqJO)#eSu#s*GNd$VGsUKt zTsOjh1(FrcyJ?=6?y~>WEW>Mwg*n6bUK^6>$7GTJH3*KMWJOZ5vSh^w>6mB7npza( zCW=XziQ3h&u%=`O=oF^rgjF-vio&szww~( znQhbIlz?sX>5`>w>+R~!K`ZRnGrKkbDafuJnZ^oks65#{?8JS>_Pz`6HRydek%1N5 zP%+*;>ZP%SyRftgAp3rX3@f;ylC^t0_`C@2!g5Z7J`C|JTfq&LwcV3p;T<-I5fM^B zhfy#MoJB#Xeji{kh)MJpL{WK2CF?owolP4CvY*eGBv_x%Lh?$^=d9}X&gbp=*e@0w zrmZg)UA9UtmOfp;_AZva&^Rukexz_n1!Da6jNE+mqCopMh7$DJ=HF4UtF6pHj_d90 z1e@!h`M)8RD(nXgG%cZvfgs{|}^cp~_=Gm8RC?jY4+u=x_b=q1PWsMP!Vf9iZ_=&0%!9 z9aHDfhioVlm9M`8$Mw)R^MmGx%Y@jM$X@oe|3E5-{+wZ7uwwWEctAa$dcHuh{trln ztc&ZCIZ%)u6EnEKo9We2u<{>BWjM;4nE*K)r>MV&!}TcC5G#wUufOY-G0=~P90@S+ z7g7n!qTE6U4W1zf!dJhiw)^}3iGrau@Uk%nyu7U%!1xbHMHd3jFsa~0=wD{w8yHY@ zeSlOpjw;Jh7f!dI8ghPRe*Lc~*h1zgYU&{~j#SupigyF>`sd?>cC1aNs`3=XqjhXK zqTCk=14E{-PLf1o7nCqh1Lk)Ll4pi;Up_>^1`n_rj_^6qxY5v;){~dukjnJHuv6oS zgK~c`KZe?Oy7O}%Bo*{ z?)YL!79_K)h8l73CwkoXoLW;X@P*yGRRRt2oYLGUt3({T@ab~L=~@j|IRz92S{ZqP8)#q2E}MPBl}Bv-XBOs7&gaM?$lEVeCz8! zcm0q`<{%}X*BTM3t>OEU{|8c;_SbG_mlg<}oJ|$29DzeBb>9{NMsx*5*Z_O?@yY!^ zQLwY{R|*}HM0{T~{-lyOIUX5vZm7Bq8hr1ZY6ZX{6*N5{b2mZ(EtQpyLX`xfz-0$s zIOqJ3mVB|;IrDqG5p&P*i^Kz@V&?AjHey=k*uO({?bQt&QnA?#Z>!WM z3pB&l*0TH!sRXKG8rpF=bvCb8K~-JDhpTh>u%#_fM7sR~ic>~T%-IYRe&j3G6c#`% z!aJo#)^fg)4J(&uJk&p@uhfiBCy3#ah*$!}Sqa^Fdx+;P-f%iqMwRTEa^KAMg`ZdE z>{(feyUop%I@Oky00r?R$-~eA+~{3<_PRF<^OAM7)7JYAO(~1N;HN;#><6xWH%q6z zaFfLP;L}aY^7TGnJBaGWIf-EQ*qT@5+goTT2QZoN9-Z|;B} zitgYF$1Z)yi|0t_NHi)!m;nRevB)X?U%yg2uXAgMPmA5sc61J2&9rfxR|ekgnn>Nu zOxv7SH>K@a*a|Exb2z^Yy>59#9ba)C&epoqa^MAPf=2MVv=QDP1}J@k;ZES<8OZ>du9c&~aNC=#cmM?J64VvRmr@Fv0eAT_EFX&{pUa4ypW-f?ZGa-JjM) zBxU!xUNrQz!1lV7?vGMvZnsErg z?y#p`0H7Cwix+C57fQVs@{AXp^26Qs!u+kM;JbLIbbRF2^t_z*zP|9LMEAKOeQ5n- z+V*A)@_EYa!#eYzsIZ{>J|pveVG1GB@&-zKzjE;vfOrcf`hv*(glBw3fPP|He$vu@ zvMzpd5I>S__q3K45(8c913%3KdRzv2kp=kxgZ#9D0$hTE5`#kOgTiKl!nuD( z!J>h|v0B0LF2RY3!O8W(sWZXpm%*82Azy(Z*;?=vS4e(hNMU_Qa(@8gzZ-Pm+eFL= z#DEUKlYhP=nK?saIkd3o)r0&qy8`k4vpqTpA1jti8RSDme*B~_hFPspZ$Oo?D*=tL zcWC@LdnlPCqr-O#El(+)LGXqBrVUjMKvdzrTFZV&HF#pX=gDccg+wxnne6OFDde?t z6=J_0M1#Jo5~r*q4eS}hl*DN{Te~)eDsinjp|bj{aaNi zw{s49sr|*!-cU~h>CU_r)@n@-_8MXUeR)1rgYhX570~sFOgP!p`xifct<@a<9;omg zMnpT0hhjnxcfnLPAK{jAa$Lx-EybUou>;(dS#^7pTPpE9SsKcV!(u<-Puokw<0 zlTzxOO8DjQCaW@F_isro_GUkvWH~KKy4UDsqw!sJtl>N}!e2+@J1N;1=Dtv`Zrd^-}~xxK_i}*?Pqz1R{X&WeK*HbCD=Rz=+pq5x&*sb{(e-BZ;i%I@U(C3pV&4TV1kTRyuRl2wo2o!Ljg}PN zp{(M#?a*?EaP0suvy!bEG4fK87RRZ9b`Pj0i6B!Kss{}<$XVN-#{yV)FMnY{cuFsZqihWD0eO~IeF{&8Fbw* zj!&{*ObjT)vu4f~w~nj|=U43EA7MfqW7W(>h;P)?LauR6quGt(Hj+ln31M|9#PKuI zD6W?dm%^bVQS##dv34vjgf&ctjGa4*K}JrFxuWkDJE?im_$P*Rx*X{EKvIG*hYOI5K^JvD?nn9eoHC${QRP*S(3>^Owq#@hMHdl=tY%~?LyUmeZ5&1 zFYC+V;O9ubisV-%ClOIs7x2qb?+14Wsf)|;1cxRI69uWq^j(ko9mTSrHk#DzIGG;~ zhMuXpshzZld>KA5xx|ZunQ-sUB966UmQBWs*Gve@-PJ5)T#CToG%6dYKIqIZ>iA&} zHq@3Mq_>5@)Ee%?4xSCOYNZT3QTWJ|su+(@Vw^j$P@%*e_Pye_?L2u`5Jp48C=|N! z0LWE1?VHfR2A9`@f%44W$XxSUw7D;9KfbeGuE3;%?GmF0UcNPOMQ_J*Rt2NbaRJ!o zWmem{GP(xJ=w0D7Ln;BjL{5n=MN2)az(B+?24YUDyGr2Qq!;5$ zZ(5et9l>tX+{-KKEUr&j1h?R z@rlLwX1h}@COb7TIP0$c-QHHZ-Rir(+D2G^f_x z;Ff9s`Zk7Is_W;NM)$r9S+CG&|5C79aFw;63kx zp`R+$=PMtmlOVz{AKZ+oSs}LW51mbxSmF-mF)0ziHAkY8j6$y#LdFWtL}rZEiaWV< z45ab@_K~#{CCsivnNxv&3KJCf?2t3=8q2KwdV4J;xmlvR{7ta#%4Mt+KWDJ)M548o^EchGT2aF8dQ`sH3QXbR^16P7 zQu{w3U&H93sacneLG+nM_Fq*X%xw&I@a|I^3jg-im0dcP4omUmgfU?lRL26AJFP3v*L1r&qmQN&{E#UlkyX#Wjg<*$aY z8J+dJ^nof-MQuU$SiTXo;9)kzB<{~lXd9A|>{YvB*dFXm1?$oBFMqDbz~L)KNQ9pw zb|l81mT%NU^<>f|-T`$X4s>2is<+p!xG}o646jK9uSF7G{#Xue9sle?=mWjj4!)DKMLwjjMilB+1xqmqYY#`?q^6C z8f6c_rUcXka6x9+wb4%l-bP{a4BFcf@z^wg@+^)=M9|nzN5P~m*)%?QV!Dkvh`T(z zLTDnpT;6!AeQcO${1x4@WdE9c+|)e!*n$P`a$pY)OcXJzTX-}jO;BCcuo3_7$wvmb zI8Gi7um1<2i;>r_$*vQd?*kE7fs)`P)e@R$R(b`8uQ+(deHIdHMMk5|8Y2E3NbHW0H2^FB$Ov#994Pf|z&z5VFcEDvw1iv1roS-_xR7Gdbs0a~fHG zefsj24l4hgG%&)-k_ZsC4=9F$Aqr+2U=u|;d zL3{$FiKcVl!kVO7!SuOu(@^QazXY@Xo hC+~rf{qWQWeFP7_|N79!2F(tlAs8t4hre0{{tE$+e5?Qf literal 0 HcmV?d00001 diff --git a/tests/core/test_event_dispatcher.py b/tests/core/test_event_dispatcher.py new file mode 100644 index 0000000000..72930b8e9c --- /dev/null +++ b/tests/core/test_event_dispatcher.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import multiprocessing +import os +import threading +import unittest +import queue + +from unittest import mock + +from proxy.common.types import DictQueueType +from proxy.core.event import EventDispatcher, EventQueue, eventNames + + +class TestEventDispatcher(unittest.TestCase): + + def setUp(self) -> None: + self.dispatcher_shutdown = threading.Event() + self.event_queue = EventQueue() + self.dispatcher = EventDispatcher( + shutdown=self.dispatcher_shutdown, + event_queue=self.event_queue) + + def tearDown(self) -> None: + self.dispatcher_shutdown.set() + + def test_empties_queue(self) -> None: + self.event_queue.publish( + request_id='1234', + event_name=eventNames.WORK_STARTED, + event_payload={'hello': 'events'}, + publisher_id=self.__class__.__name__ + ) + self.dispatcher.run_once() + with self.assertRaises(queue.Empty): + self.dispatcher.run_once() + + @mock.patch('time.time') + def subscribe(self, mock_time: mock.Mock) -> DictQueueType: + mock_time.return_value = 1234567 + q = multiprocessing.Manager().Queue() + self.event_queue.subscribe(sub_id='1234', channel=q) + self.dispatcher.run_once() + self.event_queue.publish( + request_id='1234', + event_name=eventNames.WORK_STARTED, + event_payload={'hello': 'events'}, + publisher_id=self.__class__.__name__ + ) + self.dispatcher.run_once() + self.assertEqual(q.get(), { + 'request_id': '1234', + 'process_id': os.getpid(), + 'thread_id': threading.get_ident(), + 'event_timestamp': 1234567, + 'event_name': eventNames.WORK_STARTED, + 'event_payload': {'hello': 'events'}, + 'publisher_id': self.__class__.__name__, + }) + return q + + def test_subscribe(self) -> None: + self.subscribe() + + def test_unsubscribe(self) -> None: + q = self.subscribe() + self.event_queue.unsubscribe('1234') + self.dispatcher.run_once() + self.event_queue.publish( + request_id='1234', + event_name=eventNames.WORK_STARTED, + event_payload={'hello': 'events'}, + publisher_id=self.__class__.__name__ + ) + self.dispatcher.run_once() + with self.assertRaises(queue.Empty): + q.get(timeout=0.1) + + def test_unsubscribe_on_broken_pipe_error(self) -> None: + pass + + def test_run(self) -> None: + pass diff --git a/tests/core/test_event_queue.py b/tests/core/test_event_queue.py new file mode 100644 index 0000000000..09e57bc5ef --- /dev/null +++ b/tests/core/test_event_queue.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import multiprocessing +import os +import threading +import unittest + +from unittest import mock + +from proxy.core.event import EventQueue, eventNames + + +class TestCoreEvent(unittest.TestCase): + + @mock.patch('time.time') + def test_publish(self, mock_time: mock.Mock) -> None: + mock_time.return_value = 1234567 + evq = EventQueue() + evq.publish( + request_id='1234', + event_name=eventNames.WORK_STARTED, + event_payload={'hello': 'events'}, + publisher_id=self.__class__.__name__ + ) + self.assertEqual(evq.queue.get(), { + 'request_id': '1234', + 'process_id': os.getpid(), + 'thread_id': threading.get_ident(), + 'event_timestamp': 1234567, + 'event_name': eventNames.WORK_STARTED, + 'event_payload': {'hello': 'events'}, + 'publisher_id': self.__class__.__name__, + }) + + def test_subscribe(self) -> None: + evq = EventQueue() + q = multiprocessing.Manager().Queue() + evq.subscribe('1234', q) + ev = evq.queue.get() + self.assertEqual(ev['event_name'], eventNames.SUBSCRIBE) + self.assertEqual(ev['event_payload']['sub_id'], '1234') + + def test_unsubscribe(self) -> None: + evq = EventQueue() + evq.unsubscribe('1234') + ev = evq.queue.get() + self.assertEqual(ev['event_name'], eventNames.UNSUBSCRIBE) + self.assertEqual(ev['event_payload']['sub_id'], '1234') diff --git a/tests/core/test_event_subscriber.py b/tests/core/test_event_subscriber.py new file mode 100644 index 0000000000..902f2f302a --- /dev/null +++ b/tests/core/test_event_subscriber.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import threading +import unittest +from typing import Dict, Any + +from unittest import mock + +from proxy.core.event import EventQueue, EventDispatcher, EventSubscriber, eventNames + +PUBLISHER_ID = threading.get_ident() + + +class TestEventSubscriber(unittest.TestCase): + + @mock.patch('time.time') + def test_event_subscriber(self, mock_time: mock.Mock) -> None: + mock_time.return_value = 1234567 + self.dispatcher_shutdown = threading.Event() + self.event_queue = EventQueue() + self.dispatcher = EventDispatcher( + shutdown=self.dispatcher_shutdown, + event_queue=self.event_queue) + self.subscriber = EventSubscriber(self.event_queue) + + self.subscriber.subscribe(self.callback) + self.dispatcher.run_once() + + self.event_queue.publish( + request_id='1234', + event_name=eventNames.WORK_STARTED, + event_payload={'hello': 'events'}, + publisher_id=self.__class__.__name__ + ) + self.dispatcher.run_once() + + self.subscriber.unsubscribe() + self.dispatcher.run_once() + self.dispatcher_shutdown.set() + + def callback(self, ev: Dict[str, Any]) -> None: + self.assertEqual(ev, { + 'request_id': '1234', + 'process_id': os.getpid(), + 'thread_id': PUBLISHER_ID, + 'event_timestamp': 1234567, + 'event_name': eventNames.WORK_STARTED, + 'event_payload': {'hello': 'events'}, + 'publisher_id': self.__class__.__name__, + }) From bfbbc5a82a61c697cd71b8d5f9a1088d7f64e2b7 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 30 Nov 2019 22:54:20 -0800 Subject: [PATCH 060/107] Test Dashboard backend (#206) * Update shortlink gif name * Conditionally run workflows as necessary * Use pytest * It works but github workflow is not reporting any status :( * Separate out badges * Add python_requires to setup.py --- Makefile | 2 +- README.md | 6 ++++-- setup.py | 6 ++++++ tests/dashboard/test_dashboard.py | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 tests/dashboard/test_dashboard.py diff --git a/Makefile b/Makefile index 0e7753b07d..ecafa43e34 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ lib-lint: mypy --strict --ignore-missing-imports proxy/ tests/ setup.py lib-test: lib-lint - python -m unittest discover + pytest -v tests/ lib-package: lib-clean python setup.py sdist bdist_wheel diff --git a/README.md b/README.md index 43b1e4fd62..2178be8627 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) [![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) -[![Tested With MacOS, Ubuntu, Windows, Android, Android Emulator, iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB%20%7C%20Ubuntu%20%F0%9F%96%A5%20%7C%20Windows%20%F0%9F%92%BB%20%7C%20Android%20%F0%9F%93%B1%20%7C%20Android%20Emulator%20%F0%9F%93%B1%20%7C%20iOS%20%F0%9F%93%B1%20%7C%20iOS%20Simulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) +[![Tested With MacOS, Ubuntu, Windows, Android, Android Emulator, iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB%20%7C%20Ubuntu%20%F0%9F%96%A5%20%7C%20Windows%20%F0%9F%92%BB&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) +[![Android, Android Emulator](https://img.shields.io/static/v1?label=tested%20with&message=Android%20%F0%9F%93%B1%20%7C%20Android%20Emulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) +[![iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20%F0%9F%93%B1%20%7C%20iOS%20Simulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) [![Maintenance](https://img.shields.io/static/v1?label=maintained%3F&message=yes&color=green)](https://gitHub.com/abhinavsingh/proxy.py/graphs/commit-activity) [![Ask Me Anything](https://img.shields.io/static/v1?label=need%20help%3F&message=ask&color=green)](https://twitter.com/imoracle) @@ -314,7 +316,7 @@ Plugin Examples Add support for short links in your favorite browsers / applications. -[![Shortlink Plugin](https://raw.githubusercontent.com/abhinavsingh/proxy.py/testit/shortlink.gif)](https://github.com/abhinavsingh/proxy.py#shortlinkplugin) +[![Shortlink Plugin](https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/shortlink.gif)](https://github.com/abhinavsingh/proxy.py#shortlinkplugin) Start `proxy.py` as: diff --git a/setup.py b/setup.py index 4ccfcee70c..46cc90a92e 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,8 @@ 'proxy.plugin', 'proxy.testing', ], + python_requires='!=2.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', + zip_safe=True, install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ 'console_scripts': [ @@ -98,4 +100,8 @@ 'Topic :: Utilities', 'Typing :: Typed', ], + keywords=( + 'http, proxy, http proxy server, proxy server, http server,' + 'http web server, proxy framework, web framework, Python3' + ) ) diff --git a/tests/dashboard/test_dashboard.py b/tests/dashboard/test_dashboard.py new file mode 100644 index 0000000000..88bcf0ce8c --- /dev/null +++ b/tests/dashboard/test_dashboard.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import unittest + + +class TestDashboard(unittest.TestCase): + + pass From 35ce5fe03a5525e9bf2fff83791d8ba8234e8b0a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 1 Dec 2019 20:49:26 +0200 Subject: [PATCH 061/107] Update setuptools from 42.0.1 to 42.0.2 (#207) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index abec919c24..f7e543e1d1 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.1 wheel==0.33.6 -setuptools==42.0.1 +setuptools==42.0.2 From 3bd61e038912be139feede3d260d3b244fc93644 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 12:57:21 -0800 Subject: [PATCH 062/107] Add tox.ini (#208) --- .gitignore | 1 + requirements-testing.txt | 1 + tox.ini | 8 ++++++++ 3 files changed, 10 insertions(+) create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index b9aac77751..5adcb022b3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .settings .mypy_cache .hypothesis +.tox coverage.xml proxy.py.iml diff --git a/requirements-testing.txt b/requirements-testing.txt index f6bd2cd7f2..ccdc1c5e60 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -7,3 +7,4 @@ autopep8==1.4.4 mypy==0.750 py-spy==0.3.0 codecov==2.0.15 +tox==3.14.1 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000..8fb4694b3d --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py35,py36,py37,py38 + +[testenv] +deps = + -rrequirements.txt + -rrequirements-testing.txt +command = pytest From 4713ad6ea7807975393acdff4bcf20d8c1d69627 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 19:01:28 -0800 Subject: [PATCH 063/107] Homebrew formula (#209) * Add homebrew formula * Build PyPi package and Homebrew installation verification * Check develop * bdist_wheel reported as error: invalid command "bdist_wheel" * Move under stable/develop folders to keep Proxy class name same * uff * develop installs proxy not proxy.py binary * Prepend site-packages * Install typing-extensions explicitly with brew * Use find_packages * Most likely failing due to lack of find_packages in current develop branch * Fix windows setup.py build * test_static_web_server_serves seems flaky on Ubuntu python 3.8 * Add instructions to install using homebrew * Disable test_static_web_server_serves on GitHub actions, seems flaky --- .github/workflows/test-brew.yml | 26 ++++++++++++++++++++++++++ .github/workflows/test-library.yml | 2 ++ Makefile | 2 +- README.md | 27 +++++++++++++++++++++++---- homebrew/develop/proxy.rb | 29 +++++++++++++++++++++++++++++ homebrew/stable/proxy.rb | 29 +++++++++++++++++++++++++++++ setup.py | 15 +++------------ tests/http/test_web_server.py | 9 +++------ 8 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/test-brew.yml create mode 100644 homebrew/develop/proxy.rb create mode 100644 homebrew/stable/proxy.rb diff --git a/.github/workflows/test-brew.yml b/.github/workflows/test-brew.yml new file mode 100644 index 0000000000..74e6e894d3 --- /dev/null +++ b/.github/workflows/test-brew.yml @@ -0,0 +1,26 @@ +name: Proxy.py Brew + +on: [push] + +jobs: + build: + runs-on: ${{ matrix.os }}-latest + name: Brew - Python ${{ matrix.python }} on ${{ matrix.os }} + strategy: + matrix: + os: [macOS] + python: [3.5, 3.6, 3.7, 3.8] + max-parallel: 4 + fail-fast: false + steps: + - uses: actions/checkout@v1 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python }}-dev + - name: Brew + run: | + brew install ./homebrew/develop/proxy.rb + - name: Verify + run: | + proxy --version diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 786aa0a82a..009d3d98d7 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -27,6 +27,8 @@ jobs: run: | flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ setup.py mypy --strict --ignore-missing-imports proxy/ tests/ setup.py + - name: Build PyPi Package + run: python setup.py sdist - name: Run Tests run: pytest --cov=proxy tests/ - name: Upload coverage to Codecov diff --git a/Makefile b/Makefile index ecafa43e34..bf9524b615 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ lib-test: lib-lint pytest -v tests/ lib-package: lib-clean - python setup.py sdist bdist_wheel + python setup.py sdist lib-release-test: lib-package twine upload --verbose --repository-url https://test.pypi.org/legacy/ dist/* diff --git a/README.md b/README.md index 2178be8627..8c1c68024f 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,13 @@ Table of Contents * [Features](#features) * [Install](#install) - * [Stable version](#stable-version) - * [Development version](#development-version) + * [Using PIP](#using-pip) + * [Stable version](#stable-version-with-pip) + * [Development version](#development-version-with-pip) + * [Using HomeBrew](#using-homebrew) + * [Stable version](#stable-version-with-homebrew) + * [Development version](#development-version-with-homebrew) + * [Using Docker](#using-docker) * [Start proxy.py](#start-proxypy) * [From command line when installed using PIP](#from-command-line-when-installed-using-pip) * [Run it](#run-it) @@ -153,7 +158,9 @@ Features Install ======= -## Stable version +## Using PIP + +### Stable Version with PIP Install from `PyPi` @@ -163,10 +170,22 @@ or from GitHub `master` branch $ pip install git+https://github.com/abhinavsingh/proxy.py.git@master -## Development version +### Development Version with PIP $ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop +## Using HomeBrew + +### Stable Version with HomeBrew + + $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/homebrew/stable/proxy.rb + +### Development Version with HomeBrew + + $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/homebrew/develop/proxy.rb + +## Using Docker + For `Docker` installation see [Docker Image](#docker-image). Start proxy.py diff --git a/homebrew/develop/proxy.rb b/homebrew/develop/proxy.rb new file mode 100644 index 0000000000..d8c3c37d8b --- /dev/null +++ b/homebrew/develop/proxy.rb @@ -0,0 +1,29 @@ +class Proxy < Formula + desc "⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging." + homepage "https://github.com/abhinavsingh/proxy.py" + url "https://github.com/abhinavsingh/proxy.py/archive/develop.zip" + version "HEAD" + + depends_on "python" + + resource "typing-extensions" do + url "https://files.pythonhosted.org/packages/e7/dd/f1713bc6638cc3a6a23735eff6ee09393b44b96176d3296693ada272a80b/typing_extensions-3.7.4.1.tar.gz" + sha256 "091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2" + end + + def install + xy = Language::Python.major_minor_version "python3" + + ENV.prepend_create_path "PYTHONPATH", libexec/"vendor/lib/python#{xy}/site-packages" + resource("typing-extensions").stage do + system "python3", *Language::Python.setup_install_args(libexec/"vendor") + end + + ENV.prepend_create_path "PYTHONPATH", libexec/"lib/python#{xy}/site-packages" + system "python3", *Language::Python.setup_install_args(libexec) + + bin.install Dir[libexec/"bin/*"] + bin.env_script_all_files(libexec/"bin", :PYTHONPATH => ENV["PYTHONPATH"]) + end +end diff --git a/homebrew/stable/proxy.rb b/homebrew/stable/proxy.rb new file mode 100644 index 0000000000..295bcee660 --- /dev/null +++ b/homebrew/stable/proxy.rb @@ -0,0 +1,29 @@ +class Proxy < Formula + desc "⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging." + homepage "https://github.com/abhinavsingh/proxy.py" + url "https://github.com/abhinavsingh/proxy.py/archive/master.zip" + version "1.1.1" + + depends_on "python" + + resource "typing-extensions" do + url "https://files.pythonhosted.org/packages/e7/dd/f1713bc6638cc3a6a23735eff6ee09393b44b96176d3296693ada272a80b/typing_extensions-3.7.4.1.tar.gz" + sha256 "091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2" + end + + def install + xy = Language::Python.major_minor_version "python3" + + ENV.prepend_create_path "PYTHONPATH", libexec/"vendor/lib/python#{xy}/site-packages" + resource("typing-extensions").stage do + system "python3", *Language::Python.setup_install_args(libexec/"vendor") + end + + ENV.prepend_create_path "PYTHONPATH", libexec/"lib/python#{xy}/site-packages" + system "python3", *Language::Python.setup_install_args(libexec) + + bin.install Dir[libexec/"bin/*"] + bin.env_script_all_files(libexec/"bin", :PYTHONPATH => ENV["PYTHONPATH"]) + end +end diff --git a/setup.py b/setup.py index 46cc90a92e..5e7b9d98e0 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from setuptools import setup +from setuptools import setup, find_packages VERSION = (2, 0, 0) __version__ = '.'.join(map(str, VERSION[0:3])) @@ -26,22 +26,13 @@ author_email=__author_email__, url=__homepage__, description=__description__, - long_description=open('README.md').read().strip(), + long_description=open('README.md', 'r', encoding='utf-8').read().strip(), long_description_content_type='text/markdown', download_url=__download_url__, license=__license__, - packages=[ - 'proxy', - 'proxy.benchmark', - 'proxy.common', - 'proxy.core', - 'proxy.dashboard', - 'proxy.http', - 'proxy.plugin', - 'proxy.testing', - ], python_requires='!=2.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', zip_safe=True, + packages=find_packages(exclude=["tests", "tests.*"]), install_requires=open('requirements.txt', 'r').read().strip().split(), entry_points={ 'console_scripts': [ diff --git a/tests/http/test_web_server.py b/tests/http/test_web_server.py index aeea532351..72042b40a9 100644 --- a/tests/http/test_web_server.py +++ b/tests/http/test_web_server.py @@ -110,6 +110,8 @@ def test_default_web_server_returns_404( self.protocol_handler.client.buffer[0], HttpWebServerPlugin.DEFAULT_404_RESPONSE) + @unittest.skipIf(os.environ.get('GITHUB_ACTIONS', False), + 'Disabled on GitHub actions because this test is flaky on GitHub infrastructure.') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') def test_static_web_server_serves( @@ -117,12 +119,7 @@ def test_static_web_server_serves( # Setup a static directory static_server_dir = os.path.join(tempfile.gettempdir(), 'static') index_file_path = os.path.join(static_server_dir, 'index.html') - html_file_content = b''' - - - - - ''' + html_file_content = b'''

    Proxy.py Testing

    ''' os.makedirs(static_server_dir, exist_ok=True) with open(index_file_path, 'wb') as f: f.write(html_file_content) From 54d2a1cc32f5e0cf89ede3dc5f31b0eac2ab76b4 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 20:08:50 -0800 Subject: [PATCH 064/107] Packaging (#210) * Move docker installation steps above * Try brewing with virtualenv * depends on python * Update homebrew formula for stable release * Just test brewing on latest python --- .github/workflows/test-brew.yml | 4 ++-- .gitignore | 1 + README.md | 40 ++++++++++++++++----------------- homebrew/develop/proxy.rb | 17 ++++---------- homebrew/stable/proxy.rb | 15 +++---------- 5 files changed, 29 insertions(+), 48 deletions(-) diff --git a/.github/workflows/test-brew.yml b/.github/workflows/test-brew.yml index 74e6e894d3..50fa87a290 100644 --- a/.github/workflows/test-brew.yml +++ b/.github/workflows/test-brew.yml @@ -9,8 +9,8 @@ jobs: strategy: matrix: os: [macOS] - python: [3.5, 3.6, 3.7, 3.8] - max-parallel: 4 + python: [3.8] + max-parallel: 1 fail-fast: false steps: - uses: actions/checkout@v1 diff --git a/.gitignore b/.gitignore index 5adcb022b3..716dc8656f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .coverage .coverage.* .idea diff --git a/README.md b/README.md index 8c1c68024f..703e7ed158 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,12 @@ Table of Contents * [Using PIP](#using-pip) * [Stable version](#stable-version-with-pip) * [Development version](#development-version-with-pip) + * [Using Docker](#using-docker) + * [Stable version](#stable-version-from-docker-hub) + * [Development version](#build-development-version-locally) * [Using HomeBrew](#using-homebrew) * [Stable version](#stable-version-with-homebrew) * [Development version](#development-version-with-homebrew) - * [Using Docker](#using-docker) * [Start proxy.py](#start-proxypy) * [From command line when installed using PIP](#from-command-line-when-installed-using-pip) * [Run it](#run-it) @@ -40,8 +42,6 @@ Table of Contents * [Enable DEBUG logging](#enable-debug-logging) * [From command line using repo source](#from-command-line-using-repo-source) * [Docker Image](#docker-image) - * [Stable version](#stable-version-from-docker-hub) - * [Development version](#build-development-version-locally) * [Customize Startup Flags](#customize-startup-flags) * [Plugin Examples](#plugin-examples) * [HTTP Proxy Plugins](#http-proxy-plugins) @@ -174,6 +174,22 @@ or from GitHub `master` branch $ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop +## Using Docker + +#### Stable Version from Docker Hub + + $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest + +#### Build Development Version Locally + + $ git clone https://github.com/abhinavsingh/proxy.py.git + $ cd proxy.py + $ make container + $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest + +[![WARNING](https://img.shields.io/static/v1?label=MacOS&message=warning&color=red)](https://github.com/moby/vpnkit/issues/469) +`docker` image is currently broken on `macOS` due to incompatibility with [vpnkit](https://github.com/moby/vpnkit/issues/469). + ## Using HomeBrew ### Stable Version with HomeBrew @@ -184,10 +200,6 @@ or from GitHub `master` branch $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/homebrew/develop/proxy.rb -## Using Docker - -For `Docker` installation see [Docker Image](#docker-image). - Start proxy.py ============== @@ -291,17 +303,6 @@ if you plan to work with `proxy.py` source code. ## Docker image -#### Stable Version from Docker Hub - - $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest - -#### Build Development Version Locally - - $ git clone https://github.com/abhinavsingh/proxy.py.git - $ cd proxy.py - $ make container - $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest - #### Customize startup flags By default `docker` binary is started with IPv4 networking flags: @@ -316,9 +317,6 @@ For example, to check `proxy.py` version within Docker image: --rm abhinavsingh/proxy.py:latest \ -v -[![WARNING](https://img.shields.io/static/v1?label=MacOS&message=warning&color=red)](https://github.com/moby/vpnkit/issues/469) -`docker` image is currently broken on `macOS` due to incompatibility with [vpnkit](https://github.com/moby/vpnkit/issues/469). - Plugin Examples =============== diff --git a/homebrew/develop/proxy.rb b/homebrew/develop/proxy.rb index d8c3c37d8b..36d9bb2d6f 100644 --- a/homebrew/develop/proxy.rb +++ b/homebrew/develop/proxy.rb @@ -1,9 +1,11 @@ class Proxy < Formula + include Language::Python::Virtualenv + desc "⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on Network monitoring, controls & Application development, testing, debugging." homepage "https://github.com/abhinavsingh/proxy.py" url "https://github.com/abhinavsingh/proxy.py/archive/develop.zip" - version "HEAD" + version "develop" depends_on "python" @@ -13,17 +15,6 @@ class Proxy < Formula end def install - xy = Language::Python.major_minor_version "python3" - - ENV.prepend_create_path "PYTHONPATH", libexec/"vendor/lib/python#{xy}/site-packages" - resource("typing-extensions").stage do - system "python3", *Language::Python.setup_install_args(libexec/"vendor") - end - - ENV.prepend_create_path "PYTHONPATH", libexec/"lib/python#{xy}/site-packages" - system "python3", *Language::Python.setup_install_args(libexec) - - bin.install Dir[libexec/"bin/*"] - bin.env_script_all_files(libexec/"bin", :PYTHONPATH => ENV["PYTHONPATH"]) + virtualenv_install_with_resources end end diff --git a/homebrew/stable/proxy.rb b/homebrew/stable/proxy.rb index 295bcee660..2abed63de5 100644 --- a/homebrew/stable/proxy.rb +++ b/homebrew/stable/proxy.rb @@ -1,4 +1,6 @@ class Proxy < Formula + include Language::Python::Virtualenv + desc "⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on Network monitoring, controls & Application development, testing, debugging." homepage "https://github.com/abhinavsingh/proxy.py" @@ -13,17 +15,6 @@ class Proxy < Formula end def install - xy = Language::Python.major_minor_version "python3" - - ENV.prepend_create_path "PYTHONPATH", libexec/"vendor/lib/python#{xy}/site-packages" - resource("typing-extensions").stage do - system "python3", *Language::Python.setup_install_args(libexec/"vendor") - end - - ENV.prepend_create_path "PYTHONPATH", libexec/"lib/python#{xy}/site-packages" - system "python3", *Language::Python.setup_install_args(libexec) - - bin.install Dir[libexec/"bin/*"] - bin.env_script_all_files(libexec/"bin", :PYTHONPATH => ENV["PYTHONPATH"]) + virtualenv_install_with_resources end end From fff914e22b5b15545c4be4c4481d884db61d67bb Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 20:57:05 -0800 Subject: [PATCH 065/107] Add support for regex based routing. Fixes #203 (#211) --- proxy/dashboard/dashboard.py | 16 +++---- proxy/http/inspector.py | 4 +- proxy/http/server.py | 73 +++++++++++++++++--------------- proxy/plugin/reverse_proxy.py | 4 +- proxy/plugin/web_server_route.py | 8 ++-- 5 files changed, 55 insertions(+), 50 deletions(-) diff --git a/proxy/dashboard/dashboard.py b/proxy/dashboard/dashboard.py index 845bd071b8..dda3ae0ed3 100644 --- a/proxy/dashboard/dashboard.py +++ b/proxy/dashboard/dashboard.py @@ -28,21 +28,21 @@ class ProxyDashboard(HttpWebServerBasePlugin): # Redirects to /dashboard/ REDIRECT_ROUTES = [ - (httpProtocolTypes.HTTP, b'/dashboard'), - (httpProtocolTypes.HTTPS, b'/dashboard'), - (httpProtocolTypes.HTTP, b'/dashboard/proxy.html'), - (httpProtocolTypes.HTTPS, b'/dashboard/proxy.html'), + (httpProtocolTypes.HTTP, r'/dashboard$'), + (httpProtocolTypes.HTTPS, r'/dashboard$'), + (httpProtocolTypes.HTTP, r'/dashboard/proxy.html$'), + (httpProtocolTypes.HTTPS, r'/dashboard/proxy.html$'), ] # Index html route INDEX_ROUTES = [ - (httpProtocolTypes.HTTP, b'/dashboard/'), - (httpProtocolTypes.HTTPS, b'/dashboard/'), + (httpProtocolTypes.HTTP, r'/dashboard/$'), + (httpProtocolTypes.HTTPS, r'/dashboard/$'), ] # Handles WebsocketAPI requests for dashboard WS_ROUTES = [ - (httpProtocolTypes.WEBSOCKET, b'/dashboard'), + (httpProtocolTypes.WEBSOCKET, r'/dashboard$'), ] def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -54,7 +54,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: for method in p.methods(): self.plugins[method] = p - def routes(self) -> List[Tuple[int, bytes]]: + def routes(self) -> List[Tuple[int, str]]: return ProxyDashboard.REDIRECT_ROUTES + \ ProxyDashboard.INDEX_ROUTES + \ ProxyDashboard.WS_ROUTES diff --git a/proxy/http/inspector.py b/proxy/http/inspector.py index 662bf894ee..398602e0a6 100644 --- a/proxy/http/inspector.py +++ b/proxy/http/inspector.py @@ -44,9 +44,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.subscriber = EventSubscriber(self.event_queue) - def routes(self) -> List[Tuple[int, bytes]]: + def routes(self) -> List[Tuple[int, str]]: return [ - (httpProtocolTypes.WEBSOCKET, self.flags.devtools_ws_path) + (httpProtocolTypes.WEBSOCKET, text_(self.flags.devtools_ws_path)) ] def handle_request(self, request: HttpParser) -> None: diff --git a/proxy/http/server.py b/proxy/http/server.py index 8120718a21..6651795754 100644 --- a/proxy/http/server.py +++ b/proxy/http/server.py @@ -9,13 +9,14 @@ :license: BSD, see LICENSE for more details. """ import gzip +import re import time import logging import os import mimetypes import socket from abc import ABC, abstractmethod -from typing import List, Tuple, Optional, NamedTuple, Dict, Union, Any +from typing import List, Tuple, Optional, NamedTuple, Dict, Union, Any, Pattern from .exception import HttpProtocolException from .websocket import WebsocketFrame, websocketOpcodes @@ -56,7 +57,7 @@ def __init__( self.event_queue = event_queue @abstractmethod - def routes(self) -> List[Tuple[int, bytes]]: + def routes(self) -> List[Tuple[int, str]]: """Return List(protocol, path) that this plugin handles.""" raise NotImplementedError() # pragma: no cover @@ -88,11 +89,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.pac_file_response: Optional[memoryview] = None self.cache_pac_file_response() - def routes(self) -> List[Tuple[int, bytes]]: + def routes(self) -> List[Tuple[int, str]]: if self.flags.pac_file_url_path: return [ - (httpProtocolTypes.HTTP, bytes_(self.flags.pac_file_url_path)), - (httpProtocolTypes.HTTPS, bytes_(self.flags.pac_file_url_path)), + (httpProtocolTypes.HTTP, text_(self.flags.pac_file_url_path)), + (httpProtocolTypes.HTTPS, text_(self.flags.pac_file_url_path)), ] return [] # pragma: no cover @@ -148,7 +149,7 @@ def __init__( self.start_time: float = time.time() self.pipeline_request: Optional[HttpParser] = None self.switched_protocol: Optional[int] = None - self.routes: Dict[int, Dict[bytes, HttpWebServerBasePlugin]] = { + self.routes: Dict[int, Dict[Pattern[str], HttpWebServerBasePlugin]] = { httpProtocolTypes.HTTP: {}, httpProtocolTypes.HTTPS: {}, httpProtocolTypes.WEBSOCKET: {}, @@ -162,8 +163,8 @@ def __init__( self.flags, self.client, self.event_queue) - for (protocol, path) in instance.routes(): - self.routes[protocol][path] = instance + for (protocol, route) in instance.routes(): + self.routes[protocol][re.compile(route)] = instance @staticmethod def read_and_build_static_file_response(path: str) -> memoryview: @@ -215,28 +216,35 @@ def on_request_complete(self) -> Union[socket.socket, bool]: if self.request.has_upstream_server(): return False + assert self.request.path + # If a websocket route exists for the path, try upgrade - if self.request.path in self.routes[httpProtocolTypes.WEBSOCKET]: - self.route = self.routes[httpProtocolTypes.WEBSOCKET][self.request.path] + for route in self.routes[httpProtocolTypes.WEBSOCKET]: + match = route.match(text_(self.request.path)) + if match: + self.route = self.routes[httpProtocolTypes.WEBSOCKET][route] - # Connection upgrade - teardown = self.try_upgrade() - if teardown: - return True + # Connection upgrade + teardown = self.try_upgrade() + if teardown: + return True - # For upgraded connections, nothing more to do - if self.switched_protocol: - # Invoke plugin.on_websocket_open - self.route.on_websocket_open() - return False + # For upgraded connections, nothing more to do + if self.switched_protocol: + # Invoke plugin.on_websocket_open + self.route.on_websocket_open() + return False + + break # Routing for Http(s) requests protocol = httpProtocolTypes.HTTPS \ if self.flags.encryption_enabled() else \ httpProtocolTypes.HTTP - for r in self.routes[protocol]: - if r == self.request.path: - self.route = self.routes[protocol][r] + for route in self.routes[protocol]: + match = route.match(text_(self.request.path)) + if match: + self.route = self.routes[protocol][route] self.route.handle_request(self.request) return False @@ -266,15 +274,13 @@ def on_client_data(self, raw: memoryview) -> Optional[memoryview]: while remaining != b'': # TODO: Teardown if invalid protocol exception remaining = frame.parse(remaining) - for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == self.request.path: - route = self.routes[httpProtocolTypes.WEBSOCKET][r] - if frame.opcode == websocketOpcodes.CONNECTION_CLOSE: - logger.warning( - 'Client sent connection close packet') - raise HttpProtocolException() - else: - route.on_websocket_message(frame) + if frame.opcode == websocketOpcodes.CONNECTION_CLOSE: + logger.warning( + 'Client sent connection close packet') + raise HttpProtocolException() + else: + assert self.route + self.route.on_websocket_message(frame) frame.reset() return None # If 1st valid request was completed and it's a HTTP/1.1 keep-alive @@ -305,9 +311,8 @@ def on_client_connection_close(self) -> None: return if self.switched_protocol: # Invoke plugin.on_websocket_close - for r in self.routes[httpProtocolTypes.WEBSOCKET]: - if r == self.request.path: - self.routes[httpProtocolTypes.WEBSOCKET][r].on_websocket_close() + assert self.route + self.route.on_websocket_close() self.access_log() def access_log(self) -> None: diff --git a/proxy/plugin/reverse_proxy.py b/proxy/plugin/reverse_proxy.py index 59de054454..42048d3b0c 100644 --- a/proxy/plugin/reverse_proxy.py +++ b/proxy/plugin/reverse_proxy.py @@ -43,12 +43,12 @@ class ReverseProxyPlugin(HttpWebServerBasePlugin): } """ - REVERSE_PROXY_LOCATION: bytes = b'/get' + REVERSE_PROXY_LOCATION: str = r'/get$' REVERSE_PROXY_PASS = [ b'http://httpbin.org/get' ] - def routes(self) -> List[Tuple[int, bytes]]: + def routes(self) -> List[Tuple[int, str]]: return [ (httpProtocolTypes.HTTP, ReverseProxyPlugin.REVERSE_PROXY_LOCATION), (httpProtocolTypes.HTTPS, ReverseProxyPlugin.REVERSE_PROXY_LOCATION) diff --git a/proxy/plugin/web_server_route.py b/proxy/plugin/web_server_route.py index 84be313302..c8b4731a44 100644 --- a/proxy/plugin/web_server_route.py +++ b/proxy/plugin/web_server_route.py @@ -23,11 +23,11 @@ class WebServerPlugin(HttpWebServerBasePlugin): """Demonstrates inbuilt web server routing using plugin.""" - def routes(self) -> List[Tuple[int, bytes]]: + def routes(self) -> List[Tuple[int, str]]: return [ - (httpProtocolTypes.HTTP, b'/http-route-example'), - (httpProtocolTypes.HTTPS, b'/https-route-example'), - (httpProtocolTypes.WEBSOCKET, b'/ws-route-example'), + (httpProtocolTypes.HTTP, r'/http-route-example$'), + (httpProtocolTypes.HTTPS, r'/https-route-example$'), + (httpProtocolTypes.WEBSOCKET, r'/ws-route-example$'), ] def handle_request(self, request: HttpParser) -> None: From 64192250ee1b8e99526b8866d49703c6503e873a Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 21:30:29 -0800 Subject: [PATCH 066/107] Remove public folder references (#212) --- Makefile | 4 ++-- README.md | 3 ++- proxy/common/constants.py | 3 +-- proxy/common/flags.py | 2 +- proxy/common/utils.py | 2 +- public/.gitkeep | 0 6 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 public/.gitkeep diff --git a/Makefile b/Makefile index bf9524b615..8731167180 100644 --- a/Makefile +++ b/Makefile @@ -79,10 +79,10 @@ lib-profile: sudo py-spy -F -f profile.svg -d 3600 proxy.py dashboard: - pushd dashboard && npm run build && popd && ln -s $(PWD)/dashboard/public/dashboard $(PWD)/public/dashboard + pushd dashboard && npm run build && popd dashboard-clean: - if [[ -d public/dashboard ]]; then rm -rf public/dashboard; fi + if [[ -d dashboard/public ]]; then rm -rf dashboard/public; fi container: docker build -t $(LATEST_TAG) -t $(IMAGE_TAG) . diff --git a/README.md b/README.md index 703e7ed158..e824aa8e2b 100644 --- a/README.md +++ b/README.md @@ -1289,7 +1289,8 @@ optional arguments: Default: False. Enable inbuilt static file server. Optionally, also use --static-server-dir to serve static content from custom directory. By default, - static file server serves from public folder. + static file server serves out of installed proxy.py + python module folder. --enable-web-server Default: False. Whether to enable proxy.HttpWebServerPlugin. --hostname HOSTNAME Default: ::1. Server IP address. diff --git a/proxy/common/constants.py b/proxy/common/constants.py index 3e13c27cac..dbd48ee8f2 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -68,8 +68,7 @@ DEFAULT_PLUGINS = '' DEFAULT_PORT = 8899 DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE -DEFAULT_STATIC_SERVER_DIR = os.path.join( - os.path.dirname(PROXY_PY_DIR), 'public') +DEFAULT_STATIC_SERVER_DIR = PROXY_PY_DIR DEFAULT_THREADLESS = False DEFAULT_TIMEOUT = 10 DEFAULT_VERSION = False diff --git a/proxy/common/flags.py b/proxy/common/flags.py index 37b7670979..30aaf6f1bb 100644 --- a/proxy/common/flags.py +++ b/proxy/common/flags.py @@ -374,7 +374,7 @@ def init_parser() -> argparse.ArgumentParser: help='Default: False. Enable inbuilt static file server. ' 'Optionally, also use --static-server-dir to serve static content ' 'from custom directory. By default, static file server serves ' - 'from public folder.' + 'out of installed proxy.py python module folder.' ) parser.add_argument( '--enable-web-server', diff --git a/proxy/common/utils.py b/proxy/common/utils.py index 47c4c9a4f1..ecdc4e9fe2 100644 --- a/proxy/common/utils.py +++ b/proxy/common/utils.py @@ -194,7 +194,7 @@ def __exit__( self.conn.close() def __call__(self, func: Callable[..., Any] - ) -> Callable[[socket.socket], Any]: + ) -> Callable[[Tuple[Any, ...], Dict[str, Any]], Any]: @functools.wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: with self as conn: diff --git a/public/.gitkeep b/public/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 From 6137fd6f821c87c0c74351e220038acc50272c62 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 22:46:00 -0800 Subject: [PATCH 067/107] Refactor (#213) * Add DEFAULT_HTTP_PORT constant * Use DEFAULT_HTTP_PORT in tests * Refactor into exception module * Refactor into inspector module * Refactor into server module * Refactor into proxy module --- proxy/common/constants.py | 1 + proxy/http/exception.py | 95 --------------- proxy/http/exception/__init__.py | 21 ++++ proxy/http/exception/base.py | 24 ++++ proxy/http/exception/http_request_rejected.py | 42 +++++++ proxy/http/exception/proxy_auth_failed.py | 34 ++++++ proxy/http/exception/proxy_conn_failed.py | 38 ++++++ proxy/http/inspector/__init__.py | 15 +++ proxy/http/inspector/devtools.py | 110 +++++++++++++++++ .../transformer.py} | 115 ++---------------- proxy/http/parser.py | 4 +- proxy/http/proxy/__init__.py | 17 +++ proxy/http/proxy/plugin.py | 84 +++++++++++++ proxy/http/{proxy.py => proxy/server.py} | 88 ++------------ proxy/http/server/__init__.py | 21 ++++ proxy/http/server/pac_plugin.py | 61 ++++++++++ proxy/http/server/plugin.py | 59 +++++++++ proxy/http/server/protocols.py | 18 +++ proxy/http/{server.py => server/web.py} | 115 ++---------------- proxy/plugin/reverse_proxy.py | 4 +- tests/common/test_utils.py | 3 +- tests/http/test_http_proxy.py | 7 +- .../http/test_http_proxy_tls_interception.py | 2 +- tests/http/test_protocol_handler.py | 8 +- tests/plugin/test_http_proxy_plugins.py | 16 +-- ...ttp_proxy_plugins_with_tls_interception.py | 2 +- 26 files changed, 603 insertions(+), 401 deletions(-) delete mode 100644 proxy/http/exception.py create mode 100644 proxy/http/exception/__init__.py create mode 100644 proxy/http/exception/base.py create mode 100644 proxy/http/exception/http_request_rejected.py create mode 100644 proxy/http/exception/proxy_auth_failed.py create mode 100644 proxy/http/exception/proxy_conn_failed.py create mode 100644 proxy/http/inspector/__init__.py create mode 100644 proxy/http/inspector/devtools.py rename proxy/http/{inspector.py => inspector/transformer.py} (55%) create mode 100644 proxy/http/proxy/__init__.py create mode 100644 proxy/http/proxy/plugin.py rename proxy/http/{proxy.py => proxy/server.py} (85%) create mode 100644 proxy/http/server/__init__.py create mode 100644 proxy/http/server/pac_plugin.py create mode 100644 proxy/http/server/plugin.py create mode 100644 proxy/http/server/protocols.py rename proxy/http/{server.py => server/web.py} (69%) diff --git a/proxy/common/constants.py b/proxy/common/constants.py index dbd48ee8f2..d2ec48d7f5 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -72,3 +72,4 @@ DEFAULT_THREADLESS = False DEFAULT_TIMEOUT = 10 DEFAULT_VERSION = False +DEFAULT_HTTP_PORT = 80 diff --git a/proxy/http/exception.py b/proxy/http/exception.py deleted file mode 100644 index f568394006..0000000000 --- a/proxy/http/exception.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on - Network monitoring, controls & Application development, testing, debugging. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -from typing import Optional, Dict - -from .parser import HttpParser -from .codes import httpStatusCodes - -from ..common.constants import PROXY_AGENT_HEADER_VALUE, PROXY_AGENT_HEADER_KEY -from ..common.utils import build_http_response - - -class HttpProtocolException(Exception): - """Top level HttpProtocolException exception class. - - All exceptions raised during execution of Http request lifecycle MUST - inherit HttpProtocolException base class. Implement response() method - to optionally return custom response to client.""" - - def response(self, request: HttpParser) -> Optional[memoryview]: - return None # pragma: no cover - - -class HttpRequestRejected(HttpProtocolException): - """Generic exception that can be used to reject the client requests. - - Connections can either be dropped/closed or optionally an - HTTP status code can be returned.""" - - def __init__(self, - status_code: Optional[int] = None, - reason: Optional[bytes] = None, - headers: Optional[Dict[bytes, bytes]] = None, - body: Optional[bytes] = None): - self.status_code: Optional[int] = status_code - self.reason: Optional[bytes] = reason - self.headers: Optional[Dict[bytes, bytes]] = headers - self.body: Optional[bytes] = body - - def response(self, _request: HttpParser) -> Optional[memoryview]: - if self.status_code: - return memoryview(build_http_response( - status_code=self.status_code, - reason=self.reason, - headers=self.headers, - body=self.body - )) - return None - - -class ProxyConnectionFailed(HttpProtocolException): - """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server.""" - - RESPONSE_PKT = memoryview(build_http_response( - httpStatusCodes.BAD_GATEWAY, - reason=b'Bad Gateway', - headers={ - PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, - b'Connection': b'close' - }, - body=b'Bad Gateway' - )) - - def __init__(self, host: str, port: int, reason: str): - self.host: str = host - self.port: int = port - self.reason: str = reason - - def response(self, _request: HttpParser) -> memoryview: - return self.RESPONSE_PKT - - -class ProxyAuthenticationFailed(HttpProtocolException): - """Exception raised when Http Proxy auth is enabled and - incoming request doesn't present necessary credentials.""" - - RESPONSE_PKT = memoryview(build_http_response( - httpStatusCodes.PROXY_AUTH_REQUIRED, - reason=b'Proxy Authentication Required', - headers={ - PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, - b'Proxy-Authenticate': b'Basic', - b'Connection': b'close', - }, - body=b'Proxy Authentication Required')) - - def response(self, _request: HttpParser) -> memoryview: - return self.RESPONSE_PKT diff --git a/proxy/http/exception/__init__.py b/proxy/http/exception/__init__.py new file mode 100644 index 0000000000..513d2bd510 --- /dev/null +++ b/proxy/http/exception/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .base import HttpProtocolException +from .http_request_rejected import HttpRequestRejected +from .proxy_auth_failed import ProxyAuthenticationFailed +from .proxy_conn_failed import ProxyConnectionFailed + +__all__ = [ + 'HttpProtocolException', + 'HttpRequestRejected', + 'ProxyAuthenticationFailed', + 'ProxyConnectionFailed', +] diff --git a/proxy/http/exception/base.py b/proxy/http/exception/base.py new file mode 100644 index 0000000000..65138e87b7 --- /dev/null +++ b/proxy/http/exception/base.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional + +from ..parser import HttpParser + + +class HttpProtocolException(Exception): + """Top level HttpProtocolException exception class. + + All exceptions raised during execution of Http request lifecycle MUST + inherit HttpProtocolException base class. Implement response() method + to optionally return custom response to client.""" + + def response(self, request: HttpParser) -> Optional[memoryview]: + return None # pragma: no cover diff --git a/proxy/http/exception/http_request_rejected.py b/proxy/http/exception/http_request_rejected.py new file mode 100644 index 0000000000..46fd9b04a0 --- /dev/null +++ b/proxy/http/exception/http_request_rejected.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional, Dict + +from .base import HttpProtocolException +from ..parser import HttpParser +from ...common.utils import build_http_response + + +class HttpRequestRejected(HttpProtocolException): + """Generic exception that can be used to reject the client requests. + + Connections can either be dropped/closed or optionally an + HTTP status code can be returned.""" + + def __init__(self, + status_code: Optional[int] = None, + reason: Optional[bytes] = None, + headers: Optional[Dict[bytes, bytes]] = None, + body: Optional[bytes] = None): + self.status_code: Optional[int] = status_code + self.reason: Optional[bytes] = reason + self.headers: Optional[Dict[bytes, bytes]] = headers + self.body: Optional[bytes] = body + + def response(self, _request: HttpParser) -> Optional[memoryview]: + if self.status_code: + return memoryview(build_http_response( + status_code=self.status_code, + reason=self.reason, + headers=self.headers, + body=self.body + )) + return None diff --git a/proxy/http/exception/proxy_auth_failed.py b/proxy/http/exception/proxy_auth_failed.py new file mode 100644 index 0000000000..ae1c6a4443 --- /dev/null +++ b/proxy/http/exception/proxy_auth_failed.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .base import HttpProtocolException +from ..parser import HttpParser +from ..codes import httpStatusCodes + +from ...common.constants import PROXY_AGENT_HEADER_VALUE, PROXY_AGENT_HEADER_KEY +from ...common.utils import build_http_response + + +class ProxyAuthenticationFailed(HttpProtocolException): + """Exception raised when Http Proxy auth is enabled and + incoming request doesn't present necessary credentials.""" + + RESPONSE_PKT = memoryview(build_http_response( + httpStatusCodes.PROXY_AUTH_REQUIRED, + reason=b'Proxy Authentication Required', + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + b'Proxy-Authenticate': b'Basic', + b'Connection': b'close', + }, + body=b'Proxy Authentication Required')) + + def response(self, _request: HttpParser) -> memoryview: + return self.RESPONSE_PKT diff --git a/proxy/http/exception/proxy_conn_failed.py b/proxy/http/exception/proxy_conn_failed.py new file mode 100644 index 0000000000..0cec224277 --- /dev/null +++ b/proxy/http/exception/proxy_conn_failed.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .base import HttpProtocolException +from ..parser import HttpParser +from ..codes import httpStatusCodes + +from ...common.constants import PROXY_AGENT_HEADER_VALUE, PROXY_AGENT_HEADER_KEY +from ...common.utils import build_http_response + + +class ProxyConnectionFailed(HttpProtocolException): + """Exception raised when HttpProxyPlugin is unable to establish connection to upstream server.""" + + RESPONSE_PKT = memoryview(build_http_response( + httpStatusCodes.BAD_GATEWAY, + reason=b'Bad Gateway', + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + b'Connection': b'close' + }, + body=b'Bad Gateway' + )) + + def __init__(self, host: str, port: int, reason: str): + self.host: str = host + self.port: int = port + self.reason: str = reason + + def response(self, _request: HttpParser) -> memoryview: + return self.RESPONSE_PKT diff --git a/proxy/http/inspector/__init__.py b/proxy/http/inspector/__init__.py new file mode 100644 index 0000000000..8e5d4aa017 --- /dev/null +++ b/proxy/http/inspector/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .devtools import DevtoolsProtocolPlugin + +__all__ = [ + 'DevtoolsProtocolPlugin', +] diff --git a/proxy/http/inspector/devtools.py b/proxy/http/inspector/devtools.py new file mode 100644 index 0000000000..2e9a983bf2 --- /dev/null +++ b/proxy/http/inspector/devtools.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import json +import logging +from typing import List, Tuple, Any, Dict + +from .transformer import CoreEventsToDevtoolsProtocol +from ..parser import HttpParser +from ..websocket import WebsocketFrame, websocketOpcodes +from ..server import HttpWebServerBasePlugin, httpProtocolTypes + +from ...common.utils import bytes_, text_ +from ...core.event import EventSubscriber + +logger = logging.getLogger(__name__) + + +class DevtoolsProtocolPlugin(HttpWebServerBasePlugin): + """Speaks DevTools protocol with client over websocket. + + - It responds to DevTools client request methods and also + relay proxy.py core events to the client. + - Core events are transformed into DevTools protocol format before + dispatching to client. + - Core events unrelated to DevTools protocol are dropped. + """ + + DOC_URL = 'http://dashboard.proxy.py' + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.subscriber = EventSubscriber(self.event_queue) + + def routes(self) -> List[Tuple[int, str]]: + return [ + (httpProtocolTypes.WEBSOCKET, text_(self.flags.devtools_ws_path)) + ] + + def handle_request(self, request: HttpParser) -> None: + raise NotImplementedError('This should have never been called') + + def on_websocket_open(self) -> None: + self.subscriber.subscribe( + lambda event: CoreEventsToDevtoolsProtocol.transformer(self.client, event)) + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + try: + assert frame.data + message = json.loads(frame.data) + except UnicodeDecodeError: + logger.error(frame.data) + logger.info(frame.opcode) + return + self.handle_devtools_message(message) + + def on_websocket_close(self) -> None: + self.subscriber.unsubscribe() + + def handle_devtools_message(self, message: Dict[str, Any]) -> None: + frame = WebsocketFrame() + frame.fin = True + frame.opcode = websocketOpcodes.TEXT_FRAME + + # logger.info(message) + method = message['method'] + if method in ( + 'Page.canScreencast', + 'Network.canEmulateNetworkConditions', + 'Emulation.canEmulate', + ): + data: Dict[str, Any] = { + 'result': False + } + elif method == 'Page.getResourceTree': + data = { + 'result': { + 'frameTree': { + 'frame': { + 'id': 1, + 'url': DevtoolsProtocolPlugin.DOC_URL, + 'mimeType': 'other', + }, + 'childFrames': [], + 'resources': [] + } + } + } + elif method == 'Network.getResponseBody': + connection_id = message['params']['requestId'] + data = { + 'result': { + 'body': text_(CoreEventsToDevtoolsProtocol.RESPONSES[connection_id]), + 'base64Encoded': False, + } + } + else: + logging.warning('Unhandled devtools method %s', method) + data = {} + + data['id'] = message['id'] + frame.data = bytes_(json.dumps(data)) + self.client.queue(memoryview(frame.build())) diff --git a/proxy/http/inspector.py b/proxy/http/inspector/transformer.py similarity index 55% rename from proxy/http/inspector.py rename to proxy/http/inspector/transformer.py index 398602e0a6..32d6b65ec1 100644 --- a/proxy/http/inspector.py +++ b/proxy/http/inspector/transformer.py @@ -9,114 +9,23 @@ :license: BSD, see LICENSE for more details. """ import json -import logging import secrets import time -from typing import List, Tuple, Any, Dict +from typing import Any, Dict -from .parser import HttpParser -from .websocket import WebsocketFrame, websocketOpcodes -from .server import HttpWebServerBasePlugin, httpProtocolTypes +from ..websocket import WebsocketFrame +from ...common.constants import PROXY_PY_START_TIME +from ...common.utils import bytes_ +from ...core.connection import TcpClientConnection +from ...core.event import eventNames -from ..common.constants import PROXY_PY_START_TIME -from ..common.utils import bytes_, text_ -from ..core.connection import TcpClientConnection -from ..core.event import EventSubscriber, eventNames -logger = logging.getLogger(__name__) - - -class DevtoolsProtocolPlugin(HttpWebServerBasePlugin): - """Speaks DevTools protocol with client over websocket. - - - It responds to DevTools client request methods and also - relay proxy.py core events to the client. - - Core events are transformed into DevTools protocol format before - dispatching to client. - - Core events unrelated to DevTools protocol are dropped. - """ +class CoreEventsToDevtoolsProtocol: DOC_URL = 'http://dashboard.proxy.py' FRAME_ID = secrets.token_hex(8) LOADER_ID = secrets.token_hex(8) - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.subscriber = EventSubscriber(self.event_queue) - - def routes(self) -> List[Tuple[int, str]]: - return [ - (httpProtocolTypes.WEBSOCKET, text_(self.flags.devtools_ws_path)) - ] - - def handle_request(self, request: HttpParser) -> None: - raise NotImplementedError('This should have never been called') - - def on_websocket_open(self) -> None: - self.subscriber.subscribe( - lambda event: CoreEventsToDevtoolsProtocol.transformer(self.client, event)) - - def on_websocket_message(self, frame: WebsocketFrame) -> None: - try: - assert frame.data - message = json.loads(frame.data) - except UnicodeDecodeError: - logger.error(frame.data) - logger.info(frame.opcode) - return - self.handle_devtools_message(message) - - def on_websocket_close(self) -> None: - self.subscriber.unsubscribe() - - def handle_devtools_message(self, message: Dict[str, Any]) -> None: - frame = WebsocketFrame() - frame.fin = True - frame.opcode = websocketOpcodes.TEXT_FRAME - - # logger.info(message) - method = message['method'] - if method in ( - 'Page.canScreencast', - 'Network.canEmulateNetworkConditions', - 'Emulation.canEmulate', - ): - data: Dict[str, Any] = { - 'result': False - } - elif method == 'Page.getResourceTree': - data = { - 'result': { - 'frameTree': { - 'frame': { - 'id': 1, - 'url': DevtoolsProtocolPlugin.DOC_URL, - 'mimeType': 'other', - }, - 'childFrames': [], - 'resources': [] - } - } - } - elif method == 'Network.getResponseBody': - connection_id = message['params']['requestId'] - data = { - 'result': { - 'body': text_(CoreEventsToDevtoolsProtocol.RESPONSES[connection_id]), - 'base64Encoded': False, - } - } - else: - logging.warning('Unhandled devtools method %s', method) - data = {} - - data['id'] = message['id'] - frame.data = bytes_(json.dumps(data)) - self.client.queue(memoryview(frame.build())) - - -class CoreEventsToDevtoolsProtocol: - RESPONSES: Dict[str, bytes] = {} @staticmethod @@ -145,9 +54,9 @@ def request_complete(event: Dict[str, Any]) -> Dict[str, Any]: now = time.time() return { 'requestId': event['request_id'], - 'frameId': DevtoolsProtocolPlugin.FRAME_ID, - 'loaderId': DevtoolsProtocolPlugin.LOADER_ID, - 'documentURL': DevtoolsProtocolPlugin.DOC_URL, + 'frameId': CoreEventsToDevtoolsProtocol.FRAME_ID, + 'loaderId': CoreEventsToDevtoolsProtocol.LOADER_ID, + 'documentURL': CoreEventsToDevtoolsProtocol.DOC_URL, 'timestamp': now - PROXY_PY_START_TIME, 'wallTime': now, 'hasUserGesture': False, @@ -172,8 +81,8 @@ def request_complete(event: Dict[str, Any]) -> Dict[str, Any]: def response_headers_complete(event: Dict[str, Any]) -> Dict[str, Any]: return { 'requestId': event['request_id'], - 'frameId': DevtoolsProtocolPlugin.FRAME_ID, - 'loaderId': DevtoolsProtocolPlugin.LOADER_ID, + 'frameId': CoreEventsToDevtoolsProtocol.FRAME_ID, + 'loaderId': CoreEventsToDevtoolsProtocol.LOADER_ID, 'timestamp': time.time(), 'type': event['event_payload']['headers']['content-type'] if event['event_payload']['headers'].has_header('content-type') diff --git a/proxy/http/parser.py b/proxy/http/parser.py index c30cc8aef8..80945b5d5c 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -14,7 +14,7 @@ from .methods import httpMethods from .chunk_parser import ChunkParser, chunkParserStates -from ..common.constants import DEFAULT_DISABLE_HEADERS, COLON, CRLF, WHITESPACE, HTTP_1_1 +from ..common.constants import DEFAULT_DISABLE_HEADERS, COLON, CRLF, WHITESPACE, HTTP_1_1, DEFAULT_HTTP_PORT from ..common.utils import build_http_request, find_http_line, text_ @@ -115,7 +115,7 @@ def set_line_attributes(self) -> None: self.host, self.port = u.hostname, u.port elif self.url: self.host, self.port = self.url.hostname, self.url.port \ - if self.url.port else 80 + if self.url.port else DEFAULT_HTTP_PORT else: raise KeyError( 'Invalid request. Method: %r, Url: %r' % diff --git a/proxy/http/proxy/__init__.py b/proxy/http/proxy/__init__.py new file mode 100644 index 0000000000..afd3527113 --- /dev/null +++ b/proxy/http/proxy/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .plugin import HttpProxyBasePlugin +from .server import HttpProxyPlugin + +__all__ = [ + 'HttpProxyBasePlugin', + 'HttpProxyPlugin', +] diff --git a/proxy/http/proxy/plugin.py b/proxy/http/proxy/plugin.py new file mode 100644 index 0000000000..75b5c9b803 --- /dev/null +++ b/proxy/http/proxy/plugin.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from abc import ABC, abstractmethod +from typing import Optional + +from ..parser import HttpParser + +from ...common.flags import Flags + +from ...core.event import EventQueue +from ...core.connection import TcpClientConnection + + +class HttpProxyBasePlugin(ABC): + """Base HttpProxyPlugin Plugin class. + + Implement various lifecycle event methods to customize behavior.""" + + def __init__( + self, + uid: str, + flags: Flags, + client: TcpClientConnection, + event_queue: EventQueue) -> None: + self.uid = uid # pragma: no cover + self.flags = flags # pragma: no cover + self.client = client # pragma: no cover + self.event_queue = event_queue # pragma: no cover + + def name(self) -> str: + """A unique name for your plugin. + + Defaults to name of the class. This helps plugin developers to directly + access a specific plugin by its name.""" + return self.__class__.__name__ # pragma: no cover + + @abstractmethod + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + """Handler called just before Proxy upstream connection is established. + + Return optionally modified request object. + Raise HttpRequestRejected or HttpProtocolException directly to drop the connection.""" + return request # pragma: no cover + + @abstractmethod + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + """Handler called before dispatching client request to upstream. + + Note: For pipelined (keep-alive) connections, this handler can be + called multiple times, for each request sent to upstream. + + Note: If TLS interception is enabled, this handler can + be called multiple times if client exchanges multiple + requests over same SSL session. + + Return optionally modified request object to dispatch to upstream. + Return None to drop the request data, e.g. in case a response has already been queued. + Raise HttpRequestRejected or HttpProtocolException directly to + teardown the connection with client. + """ + return request # pragma: no cover + + @abstractmethod + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: + """Handler called right after receiving raw response from upstream server. + + For HTTPS connections, chunk will be encrypted unless + TLS interception is also enabled.""" + return chunk # pragma: no cover + + @abstractmethod + def on_upstream_connection_close(self) -> None: + """Handler called right after upstream connection has been closed.""" + pass # pragma: no cover diff --git a/proxy/http/proxy.py b/proxy/http/proxy/server.py similarity index 85% rename from proxy/http/proxy.py rename to proxy/http/proxy/server.py index 1ea74e2fb9..f036ec9fde 100644 --- a/proxy/http/proxy.py +++ b/proxy/http/proxy/server.py @@ -16,91 +16,25 @@ import time import errno import logging -from abc import ABC, abstractmethod from typing import Optional, List, Union, Dict, cast, Any, Tuple -from proxy.core.event import eventNames, EventQueue -from .handler import HttpProtocolHandlerPlugin -from .exception import HttpProtocolException, ProxyConnectionFailed, ProxyAuthenticationFailed -from .codes import httpStatusCodes -from .parser import HttpParser, httpParserStates, httpParserTypes -from .methods import httpMethods +from .plugin import HttpProxyBasePlugin +from ..handler import HttpProtocolHandlerPlugin +from ..exception import HttpProtocolException, ProxyConnectionFailed, ProxyAuthenticationFailed +from ..codes import httpStatusCodes +from ..parser import HttpParser, httpParserStates, httpParserTypes +from ..methods import httpMethods -from ..common.types import HasFileno -from ..common.flags import Flags -from ..common.constants import PROXY_AGENT_HEADER_VALUE -from ..common.utils import build_http_response, text_ +from ...common.types import HasFileno +from ...common.constants import PROXY_AGENT_HEADER_VALUE +from ...common.utils import build_http_response, text_ -from ..core.connection import TcpClientConnection, TcpServerConnection, TcpConnectionUninitializedException +from ...core.event import eventNames +from ...core.connection import TcpServerConnection, TcpConnectionUninitializedException logger = logging.getLogger(__name__) -class HttpProxyBasePlugin(ABC): - """Base HttpProxyPlugin Plugin class. - - Implement various lifecycle event methods to customize behavior.""" - - def __init__( - self, - uid: str, - flags: Flags, - client: TcpClientConnection, - event_queue: EventQueue) -> None: - self.uid = uid # pragma: no cover - self.flags = flags # pragma: no cover - self.client = client # pragma: no cover - self.event_queue = event_queue # pragma: no cover - - def name(self) -> str: - """A unique name for your plugin. - - Defaults to name of the class. This helps plugin developers to directly - access a specific plugin by its name.""" - return self.__class__.__name__ # pragma: no cover - - @abstractmethod - def before_upstream_connection( - self, request: HttpParser) -> Optional[HttpParser]: - """Handler called just before Proxy upstream connection is established. - - Return optionally modified request object. - Raise HttpRequestRejected or HttpProtocolException directly to drop the connection.""" - return request # pragma: no cover - - @abstractmethod - def handle_client_request( - self, request: HttpParser) -> Optional[HttpParser]: - """Handler called before dispatching client request to upstream. - - Note: For pipelined (keep-alive) connections, this handler can be - called multiple times, for each request sent to upstream. - - Note: If TLS interception is enabled, this handler can - be called multiple times if client exchanges multiple - requests over same SSL session. - - Return optionally modified request object to dispatch to upstream. - Return None to drop the request data, e.g. in case a response has already been queued. - Raise HttpRequestRejected or HttpProtocolException directly to - teardown the connection with client. - """ - return request # pragma: no cover - - @abstractmethod - def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: - """Handler called right after receiving raw response from upstream server. - - For HTTPS connections, chunk will be encrypted unless - TLS interception is also enabled.""" - return chunk # pragma: no cover - - @abstractmethod - def on_upstream_connection_close(self) -> None: - """Handler called right after upstream connection has been closed.""" - pass # pragma: no cover - - class HttpProxyPlugin(HttpProtocolHandlerPlugin): """HttpProtocolHandler plugin which implements HttpProxy specifications.""" diff --git a/proxy/http/server/__init__.py b/proxy/http/server/__init__.py new file mode 100644 index 0000000000..059c2cc128 --- /dev/null +++ b/proxy/http/server/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .web import HttpWebServerPlugin +from .pac_plugin import HttpWebServerPacFilePlugin +from .plugin import HttpWebServerBasePlugin +from .protocols import httpProtocolTypes + +__all__ = [ + 'HttpWebServerPlugin', + 'HttpWebServerPacFilePlugin', + 'HttpWebServerBasePlugin', + 'httpProtocolTypes', +] diff --git a/proxy/http/server/pac_plugin.py b/proxy/http/server/pac_plugin.py new file mode 100644 index 0000000000..0dfa0b490a --- /dev/null +++ b/proxy/http/server/pac_plugin.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import gzip +from typing import List, Tuple, Optional, Any + +from .plugin import HttpWebServerBasePlugin +from .protocols import httpProtocolTypes +from ..websocket import WebsocketFrame +from ..parser import HttpParser +from ...common.utils import bytes_, text_, build_http_response + + +class HttpWebServerPacFilePlugin(HttpWebServerBasePlugin): + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.pac_file_response: Optional[memoryview] = None + self.cache_pac_file_response() + + def routes(self) -> List[Tuple[int, str]]: + if self.flags.pac_file_url_path: + return [ + (httpProtocolTypes.HTTP, text_(self.flags.pac_file_url_path)), + (httpProtocolTypes.HTTPS, text_(self.flags.pac_file_url_path)), + ] + return [] # pragma: no cover + + def handle_request(self, request: HttpParser) -> None: + if self.flags.pac_file and self.pac_file_response: + self.client.queue(self.pac_file_response) + + def on_websocket_open(self) -> None: + pass # pragma: no cover + + def on_websocket_message(self, frame: WebsocketFrame) -> None: + pass # pragma: no cover + + def on_websocket_close(self) -> None: + pass # pragma: no cover + + def cache_pac_file_response(self) -> None: + if self.flags.pac_file: + try: + with open(self.flags.pac_file, 'rb') as f: + content = f.read() + except IOError: + content = bytes_(self.flags.pac_file) + self.pac_file_response = memoryview(build_http_response( + 200, reason=b'OK', headers={ + b'Content-Type': b'application/x-ns-proxy-autoconfig', + b'Content-Encoding': b'gzip', + }, body=gzip.compress(content) + )) diff --git a/proxy/http/server/plugin.py b/proxy/http/server/plugin.py new file mode 100644 index 0000000000..64491a34ec --- /dev/null +++ b/proxy/http/server/plugin.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from abc import ABC, abstractmethod +from typing import List, Tuple + +from ..websocket import WebsocketFrame +from ..parser import HttpParser + +from ...common.flags import Flags +from ...core.connection import TcpClientConnection +from ...core.event import EventQueue + + +class HttpWebServerBasePlugin(ABC): + """Web Server Plugin for routing of requests.""" + + def __init__( + self, + uid: str, + flags: Flags, + client: TcpClientConnection, + event_queue: EventQueue): + self.uid = uid + self.flags = flags + self.client = client + self.event_queue = event_queue + + @abstractmethod + def routes(self) -> List[Tuple[int, str]]: + """Return List(protocol, path) that this plugin handles.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def handle_request(self, request: HttpParser) -> None: + """Handle the request and serve response.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def on_websocket_open(self) -> None: + """Called when websocket handshake has finished.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def on_websocket_message(self, frame: WebsocketFrame) -> None: + """Handle websocket frame.""" + raise NotImplementedError() # pragma: no cover + + @abstractmethod + def on_websocket_close(self) -> None: + """Called when websocket connection has been closed.""" + raise NotImplementedError() # pragma: no cover diff --git a/proxy/http/server/protocols.py b/proxy/http/server/protocols.py new file mode 100644 index 0000000000..e2a99ae9e2 --- /dev/null +++ b/proxy/http/server/protocols.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import NamedTuple + +HttpProtocolTypes = NamedTuple('HttpProtocolTypes', [ + ('HTTP', int), + ('HTTPS', int), + ('WEBSOCKET', int), +]) +httpProtocolTypes = HttpProtocolTypes(1, 2, 3) diff --git a/proxy/http/server.py b/proxy/http/server/web.py similarity index 69% rename from proxy/http/server.py rename to proxy/http/server/web.py index 6651795754..b1b9475e73 100644 --- a/proxy/http/server.py +++ b/proxy/http/server/web.py @@ -15,116 +15,23 @@ import os import mimetypes import socket -from abc import ABC, abstractmethod -from typing import List, Tuple, Optional, NamedTuple, Dict, Union, Any, Pattern +from typing import List, Tuple, Optional, Dict, Union, Any, Pattern -from .exception import HttpProtocolException -from .websocket import WebsocketFrame, websocketOpcodes -from .codes import httpStatusCodes -from .parser import HttpParser, httpParserStates, httpParserTypes -from .handler import HttpProtocolHandlerPlugin +from .plugin import HttpWebServerBasePlugin +from .protocols import httpProtocolTypes +from ..exception import HttpProtocolException +from ..websocket import WebsocketFrame, websocketOpcodes +from ..codes import httpStatusCodes +from ..parser import HttpParser, httpParserStates, httpParserTypes +from ..handler import HttpProtocolHandlerPlugin -from ..common.utils import bytes_, text_, build_http_response, build_websocket_handshake_response -from ..common.flags import Flags -from ..common.constants import PROXY_AGENT_HEADER_VALUE -from ..common.types import HasFileno -from ..core.connection import TcpClientConnection -from ..core.event import EventQueue +from ...common.utils import bytes_, text_, build_http_response, build_websocket_handshake_response +from ...common.constants import PROXY_AGENT_HEADER_VALUE +from ...common.types import HasFileno logger = logging.getLogger(__name__) -HttpProtocolTypes = NamedTuple('HttpProtocolTypes', [ - ('HTTP', int), - ('HTTPS', int), - ('WEBSOCKET', int), -]) -httpProtocolTypes = HttpProtocolTypes(1, 2, 3) - - -class HttpWebServerBasePlugin(ABC): - """Web Server Plugin for routing of requests.""" - - def __init__( - self, - uid: str, - flags: Flags, - client: TcpClientConnection, - event_queue: EventQueue): - self.uid = uid - self.flags = flags - self.client = client - self.event_queue = event_queue - - @abstractmethod - def routes(self) -> List[Tuple[int, str]]: - """Return List(protocol, path) that this plugin handles.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def handle_request(self, request: HttpParser) -> None: - """Handle the request and serve response.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def on_websocket_open(self) -> None: - """Called when websocket handshake has finished.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def on_websocket_message(self, frame: WebsocketFrame) -> None: - """Handle websocket frame.""" - raise NotImplementedError() # pragma: no cover - - @abstractmethod - def on_websocket_close(self) -> None: - """Called when websocket connection has been closed.""" - raise NotImplementedError() # pragma: no cover - - -class HttpWebServerPacFilePlugin(HttpWebServerBasePlugin): - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.pac_file_response: Optional[memoryview] = None - self.cache_pac_file_response() - - def routes(self) -> List[Tuple[int, str]]: - if self.flags.pac_file_url_path: - return [ - (httpProtocolTypes.HTTP, text_(self.flags.pac_file_url_path)), - (httpProtocolTypes.HTTPS, text_(self.flags.pac_file_url_path)), - ] - return [] # pragma: no cover - - def handle_request(self, request: HttpParser) -> None: - if self.flags.pac_file and self.pac_file_response: - self.client.queue(self.pac_file_response) - - def on_websocket_open(self) -> None: - pass # pragma: no cover - - def on_websocket_message(self, frame: WebsocketFrame) -> None: - pass # pragma: no cover - - def on_websocket_close(self) -> None: - pass # pragma: no cover - - def cache_pac_file_response(self) -> None: - if self.flags.pac_file: - try: - with open(self.flags.pac_file, 'rb') as f: - content = f.read() - except IOError: - content = bytes_(self.flags.pac_file) - self.pac_file_response = memoryview(build_http_response( - 200, reason=b'OK', headers={ - b'Content-Type': b'application/x-ns-proxy-autoconfig', - b'Content-Encoding': b'gzip', - }, body=gzip.compress(content) - )) - - class HttpWebServerPlugin(HttpProtocolHandlerPlugin): """HttpProtocolHandler plugin which handles incoming requests to local web server.""" diff --git a/proxy/plugin/reverse_proxy.py b/proxy/plugin/reverse_proxy.py index 42048d3b0c..3675b9eacd 100644 --- a/proxy/plugin/reverse_proxy.py +++ b/proxy/plugin/reverse_proxy.py @@ -12,7 +12,7 @@ from typing import List, Tuple from urllib import parse as urlparse -from ..common.constants import DEFAULT_BUFFER_SIZE +from ..common.constants import DEFAULT_BUFFER_SIZE, DEFAULT_HTTP_PORT from ..common.utils import socket_connection, text_ from ..http.parser import HttpParser from ..http.websocket import WebsocketFrame @@ -58,7 +58,7 @@ def handle_request(self, request: HttpParser) -> None: upstream = random.choice(ReverseProxyPlugin.REVERSE_PROXY_PASS) url = urlparse.urlsplit(upstream) assert url.hostname - with socket_connection((text_(url.hostname), url.port if url.port else 80)) as conn: + with socket_connection((text_(url.hostname), url.port if url.port else DEFAULT_HTTP_PORT)) as conn: conn.send(request.build()) self.client.queue(memoryview(conn.recv(DEFAULT_BUFFER_SIZE))) diff --git a/tests/common/test_utils.py b/tests/common/test_utils.py index 27ada7ba9f..b75b2b569c 100644 --- a/tests/common/test_utils.py +++ b/tests/common/test_utils.py @@ -13,6 +13,7 @@ from unittest import mock from proxy.common.constants import DEFAULT_IPV6_HOSTNAME, DEFAULT_IPV4_HOSTNAME, DEFAULT_PORT, DEFAULT_TIMEOUT +from proxy.common.constants import DEFAULT_HTTP_PORT from proxy.common.utils import new_socket_connection, socket_connection @@ -21,7 +22,7 @@ class TestSocketConnectionUtils(unittest.TestCase): def setUp(self) -> None: self.addr_ipv4 = (str(DEFAULT_IPV4_HOSTNAME), DEFAULT_PORT) self.addr_ipv6 = (str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT) - self.addr_dual = ('httpbin.org', 80) + self.addr_dual = ('httpbin.org', DEFAULT_HTTP_PORT) @mock.patch('socket.socket') def test_new_socket_connection_ipv4(self, mock_socket: mock.Mock) -> None: diff --git a/tests/http/test_http_proxy.py b/tests/http/test_http_proxy.py index e3d861af7d..3bb2648ae4 100644 --- a/tests/http/test_http_proxy.py +++ b/tests/http/test_http_proxy.py @@ -12,6 +12,7 @@ import selectors from unittest import mock +from proxy.common.constants import DEFAULT_HTTP_PORT from proxy.common.flags import Flags from proxy.http.proxy import HttpProxyPlugin from proxy.http.handler import HttpProtocolHandler @@ -45,7 +46,7 @@ def setUp(self, def test_proxy_plugin_initialized(self) -> None: self.plugin.assert_called() - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_proxy_plugin_on_and_before_upstream_connection( self, mock_server_conn: mock.Mock) -> None: @@ -65,11 +66,11 @@ def test_proxy_plugin_on_and_before_upstream_connection( data=None), selectors.EVENT_READ)], ] self.protocol_handler.run_once() - mock_server_conn.assert_called_with('upstream.host', 80) + mock_server_conn.assert_called_with('upstream.host', DEFAULT_HTTP_PORT) self.plugin.return_value.before_upstream_connection.assert_called() self.plugin.return_value.handle_client_request.assert_called() - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_proxy_plugin_before_upstream_connection_can_teardown( self, mock_server_conn: mock.Mock) -> None: diff --git a/tests/http/test_http_proxy_tls_interception.py b/tests/http/test_http_proxy_tls_interception.py index 68dc06f0e4..87c3eed780 100644 --- a/tests/http/test_http_proxy_tls_interception.py +++ b/tests/http/test_http_proxy_tls_interception.py @@ -28,7 +28,7 @@ class TestHttpProxyTlsInterception(unittest.TestCase): @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') @mock.patch('subprocess.Popen') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') diff --git a/tests/http/test_protocol_handler.py b/tests/http/test_protocol_handler.py index 1356e9c55a..f4140fe998 100644 --- a/tests/http/test_protocol_handler.py +++ b/tests/http/test_protocol_handler.py @@ -47,7 +47,7 @@ def setUp(self, self.fileno, self._addr, flags=self.flags) self.protocol_handler.initialize() - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_http_get(self, mock_server_connection: mock.Mock) -> None: server = mock_server_connection.return_value server.connect.return_value = True @@ -99,7 +99,7 @@ def assert_tunnel_response( assert parser.code is not None self.assertEqual(int(parser.code), 200) - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_http_tunnel(self, mock_server_connection: mock.Mock) -> None: server = mock_server_connection.return_value server.connect.return_value = True @@ -189,7 +189,7 @@ def test_proxy_authentication_failed( @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_authenticated_proxy_http_get( self, mock_server_connection: mock.Mock, mock_fromfd: mock.Mock, @@ -237,7 +237,7 @@ def test_authenticated_proxy_http_get( @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_authenticated_proxy_http_tunnel( self, mock_server_connection: mock.Mock, mock_fromfd: mock.Mock, diff --git a/tests/plugin/test_http_proxy_plugins.py b/tests/plugin/test_http_proxy_plugins.py index b17d5729e5..a768c1013e 100644 --- a/tests/plugin/test_http_proxy_plugins.py +++ b/tests/plugin/test_http_proxy_plugins.py @@ -20,7 +20,7 @@ from proxy.http.handler import HttpProtocolHandler from proxy.http.proxy import HttpProxyPlugin from proxy.common.utils import build_http_request, bytes_, build_http_response -from proxy.common.constants import PROXY_AGENT_HEADER_VALUE +from proxy.common.constants import PROXY_AGENT_HEADER_VALUE, DEFAULT_HTTP_PORT from proxy.http.codes import httpStatusCodes from proxy.plugin import ProposedRestApiPlugin, RedirectToCustomServerPlugin @@ -54,7 +54,7 @@ def setUp(self, self.fileno, self._addr, flags=self.flags) self.protocol_handler.initialize() - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_modify_post_data_plugin( self, mock_server_conn: mock.Mock) -> None: original = b'{"key": "value"}' @@ -77,7 +77,7 @@ def test_modify_post_data_plugin( data=None), selectors.EVENT_READ)], ] self.protocol_handler.run_once() - mock_server_conn.assert_called_with('httpbin.org', 80) + mock_server_conn.assert_called_with('httpbin.org', DEFAULT_HTTP_PORT) mock_server_conn.return_value.queue.assert_called_with( build_http_request( b'POST', b'/post', @@ -91,7 +91,7 @@ def test_modify_post_data_plugin( ) ) - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_proposed_rest_api_plugin( self, mock_server_conn: mock.Mock) -> None: path = b'/v1/users/' @@ -121,7 +121,7 @@ def test_proposed_rest_api_plugin( ProposedRestApiPlugin.REST_API_SPEC[path])) )) - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_redirect_to_custom_server_plugin( self, mock_server_conn: mock.Mock) -> None: request = build_http_request( @@ -152,7 +152,7 @@ def test_redirect_to_custom_server_plugin( ) ) - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_filter_by_upstream_host_plugin( self, mock_server_conn: mock.Mock) -> None: request = build_http_request( @@ -182,7 +182,7 @@ def test_filter_by_upstream_host_plugin( ) ) - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') def test_man_in_the_middle_plugin( self, mock_server_conn: mock.Mock) -> None: request = build_http_request( @@ -224,7 +224,7 @@ def closed() -> bool: # Client read self.protocol_handler.run_once() - mock_server_conn.assert_called_with('super.secure', 80) + mock_server_conn.assert_called_with('super.secure', DEFAULT_HTTP_PORT) server.connect.assert_called_once() queued_request = \ build_http_request( diff --git a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py index 2ef478c4e7..ad05b2b1a3 100644 --- a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py +++ b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py @@ -31,7 +31,7 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase): @mock.patch('ssl.wrap_socket') @mock.patch('ssl.create_default_context') - @mock.patch('proxy.http.proxy.TcpServerConnection') + @mock.patch('proxy.http.proxy.server.TcpServerConnection') @mock.patch('subprocess.Popen') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') From 419026dcdfd66f023b0e8639fb4fa163682c9866 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 23:06:50 -0800 Subject: [PATCH 068/107] Build docker of Python 3.8 (#214) --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 520ba988d1..60f88802d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7-alpine as base +FROM python:3.8-alpine as base FROM base as builder COPY requirements.txt /app/ @@ -12,7 +12,8 @@ RUN pip install --upgrade pip && \ FROM base LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ - com.abhinavsingh.description="⚡⚡⚡Fast, Lightweight, Programmable, TLS interception capable proxy server for Application debugging, testing and development" \ + com.abhinavsingh.description="⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on \ + Network monitoring, controls & Application development, testing, debugging." \ com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py" From 7f9ffad6d4aacf131d5b09b241091909b8014f65 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sun, 1 Dec 2019 23:28:46 -0800 Subject: [PATCH 069/107] Move homebrew under helper (#215) --- .github/workflows/test-brew.yml | 4 ++-- README.md | 4 ++-- {homebrew => helper/homebrew}/develop/proxy.rb | 0 {homebrew => helper/homebrew}/stable/proxy.rb | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename {homebrew => helper/homebrew}/develop/proxy.rb (100%) rename {homebrew => helper/homebrew}/stable/proxy.rb (100%) diff --git a/.github/workflows/test-brew.yml b/.github/workflows/test-brew.yml index 50fa87a290..10d217aafb 100644 --- a/.github/workflows/test-brew.yml +++ b/.github/workflows/test-brew.yml @@ -20,7 +20,7 @@ jobs: python-version: ${{ matrix.python }}-dev - name: Brew run: | - brew install ./homebrew/develop/proxy.rb + brew install ./helper/homebrew/develop/proxy.rb - name: Verify run: | - proxy --version + proxy -h diff --git a/README.md b/README.md index e824aa8e2b..c1be586376 100644 --- a/README.md +++ b/README.md @@ -194,11 +194,11 @@ or from GitHub `master` branch ### Stable Version with HomeBrew - $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/homebrew/stable/proxy.rb + $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/helper/homebrew/stable/proxy.rb ### Development Version with HomeBrew - $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/homebrew/develop/proxy.rb + $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/helper/homebrew/develop/proxy.rb Start proxy.py ============== diff --git a/homebrew/develop/proxy.rb b/helper/homebrew/develop/proxy.rb similarity index 100% rename from homebrew/develop/proxy.rb rename to helper/homebrew/develop/proxy.rb diff --git a/homebrew/stable/proxy.rb b/helper/homebrew/stable/proxy.rb similarity index 100% rename from homebrew/stable/proxy.rb rename to helper/homebrew/stable/proxy.rb From 5e714374afa750b734eabe8c5fcbc132ed0213c9 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 2 Dec 2019 00:08:15 -0800 Subject: [PATCH 070/107] Handle ETIMEDOUT, EHOSTUNREACH, ECONNRESET on no internet (#216) * Catch TimeoutError and OSError (host unreachable) * Handle ETIMEDOUT, EHOSTUNREACH, ECONNRESET --- README.md | 7 +++++-- proxy/http/proxy/server.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c1be586376..f0b70cf4aa 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ [![Proxy.Py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/ProxyPy.png)](https://github.com/abhinavsingh/proxy.py) [![License](https://img.shields.io/github/license/abhinavsingh/proxy.py.svg)](https://opensource.org/licenses/BSD-3-Clause) -[![PyPi Downloads](https://img.shields.io/pypi/dm/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) -[![Docker Pulls](https://img.shields.io/docker/pulls/abhinavsingh/proxy.py?color=green)](https://hub.docker.com/r/abhinavsingh/proxy.py) [![Build Status](https://travis-ci.org/abhinavsingh/proxy.py.svg?branch=develop)](https://travis-ci.org/abhinavsingh/proxy.py/) [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) [![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) +[![PyPi Daily](https://img.shields.io/pypi/dd/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) +[![PyPi Weekly](https://img.shields.io/pypi/dw/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) +[![PyPi Monthly](https://img.shields.io/pypi/dm/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) +[![Docker Pulls](https://img.shields.io/docker/pulls/abhinavsingh/proxy.py?color=green)](https://hub.docker.com/r/abhinavsingh/proxy.py) + [![Tested With MacOS, Ubuntu, Windows, Android, Android Emulator, iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB%20%7C%20Ubuntu%20%F0%9F%96%A5%20%7C%20Windows%20%F0%9F%92%BB&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) [![Android, Android Emulator](https://img.shields.io/static/v1?label=tested%20with&message=Android%20%F0%9F%93%B1%20%7C%20Android%20Emulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) [![iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20%F0%9F%93%B1%20%7C%20iOS%20Simulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) diff --git a/proxy/http/proxy/server.py b/proxy/http/proxy/server.py index f036ec9fde..14b475f818 100644 --- a/proxy/http/proxy/server.py +++ b/proxy/http/proxy/server.py @@ -104,11 +104,20 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: logger.debug('Server is ready for reads, reading...') try: raw = self.server.recv(self.flags.server_recvbuf_size) + except TimeoutError as e: + if e.errno == errno.ETIMEDOUT: + logger.warning('%s:%d timed out on recv' % self.server.addr) + return True + else: + raise e except ssl.SSLWantReadError: # Try again later # logger.warning('SSLWantReadError encountered while reading from server, will retry ...') return False - except socket.error as e: - if e.errno == errno.ECONNRESET: + except OSError as e: + if e.errno == errno.EHOSTUNREACH: + logger.warning('%s:%d unreachable on recv' % self.server.addr) + return True + elif e.errno == errno.ECONNRESET: logger.warning('Connection reset by upstream: %r' % e) else: logger.exception( From 8971f82f8284b1c9a43419807ee94812cbbcaf6d Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 2 Dec 2019 10:04:27 -0800 Subject: [PATCH 071/107] Enable mccabe (#217) --- .github/workflows/test-library.yml | 2 +- Makefile | 2 +- requirements-testing.txt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 009d3d98d7..b973da3310 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -25,7 +25,7 @@ jobs: pip install -r requirements-testing.txt - name: Quality Check run: | - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ setup.py + flake8 --ignore=W504 --max-line-length=127 --max-complexity=19 proxy/ tests/ setup.py mypy --strict --ignore-missing-imports proxy/ tests/ setup.py - name: Build PyPi Package run: python setup.py sdist diff --git a/Makefile b/Makefile index 8731167180..0dfd26ed85 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ lib-clean: rm -rf .hypothesis lib-lint: - flake8 --ignore=W504 --max-line-length=127 proxy/ tests/ setup.py + flake8 --ignore=W504 --max-line-length=127 --max-complexity=19 proxy/ tests/ setup.py mypy --strict --ignore-missing-imports proxy/ tests/ setup.py lib-test: lib-lint diff --git a/requirements-testing.txt b/requirements-testing.txt index ccdc1c5e60..58fb0e2b00 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -8,3 +8,4 @@ mypy==0.750 py-spy==0.3.0 codecov==2.0.15 tox==3.14.1 +mccabe==0.6.1 From cc7e4f5cbf27f4a996918b1694b23b10c8b63457 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 2 Dec 2019 10:13:25 -0800 Subject: [PATCH 072/107] No need of per day or week stats (#218) --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index f0b70cf4aa..7346c35482 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,6 @@ [![Build Status](https://travis-ci.org/abhinavsingh/proxy.py.svg?branch=develop)](https://travis-ci.org/abhinavsingh/proxy.py/) [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) [![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) - -[![PyPi Daily](https://img.shields.io/pypi/dd/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) -[![PyPi Weekly](https://img.shields.io/pypi/dw/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) [![PyPi Monthly](https://img.shields.io/pypi/dm/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) [![Docker Pulls](https://img.shields.io/docker/pulls/abhinavsingh/proxy.py?color=green)](https://hub.docker.com/r/abhinavsingh/proxy.py) From 269484df2e89bc659124177d339d4fc59f280cba Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 2 Dec 2019 15:55:08 -0800 Subject: [PATCH 073/107] Make HTTP handler constructor free of socket file number (#219) * Refactor into acceptor module * Add tunnel doc * Make fileno free * Autopep8 --- README.md | 91 +++++++++++- proxy/core/acceptor/__init__.py | 17 +++ proxy/core/acceptor/acceptor.py | 137 ++++++++++++++++++ proxy/core/{acceptor.py => acceptor/pool.py} | 124 +--------------- proxy/core/threadless.py | 16 +- proxy/http/handler.py | 16 +- proxy/http/proxy/server.py | 8 +- tests/core/test_acceptor.py | 11 +- tests/core/test_acceptor_pool.py | 41 +++--- tests/http/test_http_proxy.py | 4 +- .../http/test_http_proxy_tls_interception.py | 4 +- tests/http/test_protocol_handler.py | 11 +- tests/http/test_web_server.py | 19 ++- tests/plugin/test_http_proxy_plugins.py | 4 +- ...ttp_proxy_plugins_with_tls_interception.py | 3 +- 15 files changed, 326 insertions(+), 180 deletions(-) create mode 100644 proxy/core/acceptor/__init__.py create mode 100644 proxy/core/acceptor/acceptor.py rename proxy/core/{acceptor.py => acceptor/pool.py} (51%) diff --git a/README.md b/README.md index 7346c35482..dd13814a05 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![License](https://img.shields.io/github/license/abhinavsingh/proxy.py.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Build Status](https://travis-ci.org/abhinavsingh/proxy.py.svg?branch=develop)](https://travis-ci.org/abhinavsingh/proxy.py/) [![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) -[![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) [![PyPi Monthly](https://img.shields.io/pypi/dm/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) [![Docker Pulls](https://img.shields.io/docker/pulls/abhinavsingh/proxy.py?color=green)](https://hub.docker.com/r/abhinavsingh/proxy.py) +[![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) [![Tested With MacOS, Ubuntu, Windows, Android, Android Emulator, iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB%20%7C%20Ubuntu%20%F0%9F%96%A5%20%7C%20Windows%20%F0%9F%92%BB&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) [![Android, Android Emulator](https://img.shields.io/static/v1?label=tested%20with&message=Android%20%F0%9F%93%B1%20%7C%20Android%20Emulator%20%F0%9F%93%B1&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) @@ -58,6 +58,9 @@ Table of Contents * [Plugin Ordering](#plugin-ordering) * [End-to-End Encryption](#end-to-end-encryption) * [TLS Interception](#tls-interception) +* [Proxy Over SSH Tunnel](#proxy-over-ssh-tunnel) + * [Proxy Remote Requests Locally](#proxy-remote-requests-locally) + * [Proxy Local Requests Remotely](#proxy-local-requests-remotely) * [Embed proxy.py](#embed-proxypy) * [Blocking Mode](#blocking-mode) * [Non-blocking Mode](#non-blocking-mode) @@ -798,6 +801,92 @@ cached file instead of plain text. Now use CA flags with other [plugin examples](#plugin-examples) to see them work with `https` traffic. +Proxy Over SSH Tunnel +===================== + +Requires `paramiko` to work. See [requirements-tunnel.txt](https://github.com/abhinavsingh/proxy.py/blob/develop/requirements-tunnel.txt) + +## Proxy Remote Requests Locally + + | + +------------+ | +----------+ + | LOCAL | | | REMOTE | + | HOST | <== SSH ==== :8900 == | SERVER | + +------------+ | +----------+ + :8899 proxy.py | + | + FIREWALL + (allow tcp/22) + +## What + +Proxy HTTP(s) requests made on a `remote` server through `proxy.py` server +running on `localhost`. + +### How + +* Requested `remote` port is forwarded over the SSH connection. +* `proxy.py` running on the `localhost` handles and responds to + `remote` proxy requests. + +### Requirements + +1. `localhost` MUST have SSH access to the `remote` server +2. `remote` server MUST be configured to proxy HTTP(s) requests + through the forwarded port number e.g. `:8900`. + - `remote` and `localhost` ports CAN be same e.g. `:8899`. + - `:8900` is chosen in ascii art for differentiation purposes. + +### Try it + +Start `proxy.py` as: + +``` +$ # On localhost +$ proxy --enable-tunnel \ + --tunnel-username username \ + --tunnel-hostname ip.address.or.domain.name \ + --tunnel-port 22 \ + --tunnel-remote-host 127.0.0.1 + --tunnel-remote-port 8899 +``` + +Make a HTTP proxy request on `remote` server and +verify that response contains public IP address of `localhost` as origin: + +``` +$ # On remote +$ curl -x 127.0.0.1:8899 http://httpbin.org/get +{ + "args": {}, + "headers": { + "Accept": "*/*", + "Host": "httpbin.org", + "User-Agent": "curl/7.54.0" + }, + "origin": "x.x.x.x, y.y.y.y", + "url": "https://httpbin.org/get" +} +``` + +Also, verify that `proxy.py` logs on `localhost` contains `remote` IP as client IP. + +``` +access_log:328 - remote:52067 - GET httpbin.org:80 +``` + +## Proxy Local Requests Remotely + + | + +------------+ | +----------+ + | LOCAL | | | REMOTE | + | HOST | === SSH =====> | SERVER | + +------------+ | +----------+ + | :8899 proxy.py + | + FIREWALL + (allow tcp/22) + Embed proxy.py ============== diff --git a/proxy/core/acceptor/__init__.py b/proxy/core/acceptor/__init__.py new file mode 100644 index 0000000000..9c0a97b332 --- /dev/null +++ b/proxy/core/acceptor/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .acceptor import Acceptor +from .pool import AcceptorPool + +__all__ = [ + 'Acceptor', + 'AcceptorPool', +] diff --git a/proxy/core/acceptor/acceptor.py b/proxy/core/acceptor/acceptor.py new file mode 100644 index 0000000000..648f51edc5 --- /dev/null +++ b/proxy/core/acceptor/acceptor.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging +import multiprocessing +import selectors +import socket +import threading +# import time +from multiprocessing import connection +from multiprocessing.reduction import send_handle, recv_handle +from typing import Optional, Type, Tuple + +from ..connection import TcpClientConnection +from ..threadless import ThreadlessWork, Threadless +from ..event import EventQueue, eventNames +from ...common.flags import Flags + +logger = logging.getLogger(__name__) + + +class Acceptor(multiprocessing.Process): + """Socket client acceptor. + + Accepts client connection over received server socket handle and + starts a new work thread. + """ + + lock = multiprocessing.Lock() + + def __init__( + self, + idd: int, + work_queue: connection.Connection, + flags: Flags, + work_klass: Type[ThreadlessWork], + event_queue: Optional[EventQueue] = None) -> None: + super().__init__() + self.idd = idd + self.work_queue: connection.Connection = work_queue + self.flags = flags + self.work_klass = work_klass + self.event_queue = event_queue + + self.running = multiprocessing.Event() + self.selector: Optional[selectors.DefaultSelector] = None + self.sock: Optional[socket.socket] = None + self.threadless_process: Optional[Threadless] = None + self.threadless_client_queue: Optional[connection.Connection] = None + + def start_threadless_process(self) -> None: + pipe = multiprocessing.Pipe() + self.threadless_client_queue = pipe[0] + self.threadless_process = Threadless( + client_queue=pipe[1], + flags=self.flags, + work_klass=self.work_klass, + event_queue=self.event_queue + ) + self.threadless_process.start() + logger.debug('Started process %d', self.threadless_process.pid) + + def shutdown_threadless_process(self) -> None: + assert self.threadless_process and self.threadless_client_queue + logger.debug('Stopped process %d', self.threadless_process.pid) + self.threadless_process.running.set() + self.threadless_process.join() + self.threadless_client_queue.close() + + def start_work(self, conn: socket.socket, addr: Tuple[str, int]) -> None: + if self.flags.threadless and \ + self.threadless_client_queue and \ + self.threadless_process: + self.threadless_client_queue.send(addr) + send_handle( + self.threadless_client_queue, + conn.fileno(), + self.threadless_process.pid + ) + conn.close() + else: + work = self.work_klass( + TcpClientConnection(conn, addr), + flags=self.flags, + event_queue=self.event_queue + ) + work_thread = threading.Thread(target=work.run) + work_thread.daemon = True + work.publish_event( + event_name=eventNames.WORK_STARTED, + event_payload={'fileno': conn.fileno(), 'addr': addr}, + publisher_id=self.__class__.__name__ + ) + work_thread.start() + + def run_once(self) -> None: + assert self.selector and self.sock + with self.lock: + events = self.selector.select(timeout=1) + if len(events) == 0: + return + conn, addr = self.sock.accept() + # now = time.time() + # fileno: int = conn.fileno() + self.start_work(conn, addr) + # logger.info('Work started for fd %d in %f seconds', fileno, time.time() - now) + + def run(self) -> None: + self.selector = selectors.DefaultSelector() + fileno = recv_handle(self.work_queue) + self.work_queue.close() + self.sock = socket.fromfd( + fileno, + family=self.flags.family, + type=socket.SOCK_STREAM + ) + try: + self.selector.register(self.sock, selectors.EVENT_READ) + if self.flags.threadless: + self.start_threadless_process() + while not self.running.is_set(): + self.run_once() + except KeyboardInterrupt: + pass + finally: + self.selector.unregister(self.sock) + if self.flags.threadless: + self.shutdown_threadless_process() + self.sock.close() + logger.debug('Acceptor#%d shutdown', self.idd) diff --git a/proxy/core/acceptor.py b/proxy/core/acceptor/pool.py similarity index 51% rename from proxy/core/acceptor.py rename to proxy/core/acceptor/pool.py index 2c376dbbce..4b83b9ae95 100644 --- a/proxy/core/acceptor.py +++ b/proxy/core/acceptor/pool.py @@ -10,17 +10,17 @@ """ import logging import multiprocessing -import selectors import socket import threading # import time from multiprocessing import connection -from multiprocessing.reduction import send_handle, recv_handle -from typing import List, Optional, Type, Tuple +from multiprocessing.reduction import send_handle +from typing import List, Optional, Type -from .threadless import ThreadlessWork, Threadless -from .event import EventQueue, EventDispatcher, eventNames -from ..common.flags import Flags +from .acceptor import Acceptor +from ..threadless import ThreadlessWork +from ..event import EventQueue, EventDispatcher +from ...common.flags import Flags logger = logging.getLogger(__name__) @@ -125,115 +125,3 @@ def setup(self) -> None: ) self.work_queues[index].close() self.socket.close() - - -class Acceptor(multiprocessing.Process): - """Socket client acceptor. - - Accepts client connection over received server socket handle and - starts a new work thread. - """ - - lock = multiprocessing.Lock() - - def __init__( - self, - idd: int, - work_queue: connection.Connection, - flags: Flags, - work_klass: Type[ThreadlessWork], - event_queue: Optional[EventQueue] = None) -> None: - super().__init__() - self.idd = idd - self.work_queue: connection.Connection = work_queue - self.flags = flags - self.work_klass = work_klass - self.event_queue = event_queue - - self.running = multiprocessing.Event() - self.selector: Optional[selectors.DefaultSelector] = None - self.sock: Optional[socket.socket] = None - self.threadless_process: Optional[Threadless] = None - self.threadless_client_queue: Optional[connection.Connection] = None - - def start_threadless_process(self) -> None: - pipe = multiprocessing.Pipe() - self.threadless_client_queue = pipe[0] - self.threadless_process = Threadless( - client_queue=pipe[1], - flags=self.flags, - work_klass=self.work_klass, - event_queue=self.event_queue - ) - self.threadless_process.start() - logger.debug('Started process %d', self.threadless_process.pid) - - def shutdown_threadless_process(self) -> None: - assert self.threadless_process and self.threadless_client_queue - logger.debug('Stopped process %d', self.threadless_process.pid) - self.threadless_process.running.set() - self.threadless_process.join() - self.threadless_client_queue.close() - - def start_work(self, conn: socket.socket, addr: Tuple[str, int]) -> None: - if self.flags.threadless and \ - self.threadless_client_queue and \ - self.threadless_process: - self.threadless_client_queue.send(addr) - send_handle( - self.threadless_client_queue, - conn.fileno(), - self.threadless_process.pid - ) - conn.close() - else: - work = self.work_klass( - fileno=conn.fileno(), - addr=addr, - flags=self.flags, - event_queue=self.event_queue - ) - work_thread = threading.Thread(target=work.run) - work_thread.daemon = True - work.publish_event( - event_name=eventNames.WORK_STARTED, - event_payload={'fileno': conn.fileno(), 'addr': addr}, - publisher_id=self.__class__.__name__ - ) - work_thread.start() - - def run_once(self) -> None: - assert self.selector and self.sock - with self.lock: - events = self.selector.select(timeout=1) - if len(events) == 0: - return - conn, addr = self.sock.accept() - # now = time.time() - # fileno: int = conn.fileno() - self.start_work(conn, addr) - # logger.info('Work started for fd %d in %f seconds', fileno, time.time() - now) - - def run(self) -> None: - self.selector = selectors.DefaultSelector() - fileno = recv_handle(self.work_queue) - self.work_queue.close() - self.sock = socket.fromfd( - fileno, - family=self.flags.family, - type=socket.SOCK_STREAM - ) - try: - self.selector.register(self.sock, selectors.EVENT_READ) - if self.flags.threadless: - self.start_threadless_process() - while not self.running.is_set(): - self.run_once() - except KeyboardInterrupt: - pass - finally: - self.selector.unregister(self.sock) - if self.flags.threadless: - self.shutdown_threadless_process() - self.sock.close() - logger.debug('Acceptor#%d shutdown', self.idd) diff --git a/proxy/core/threadless.py b/proxy/core/threadless.py index 750588906f..d13f973aa8 100644 --- a/proxy/core/threadless.py +++ b/proxy/core/threadless.py @@ -22,6 +22,7 @@ from abc import ABC, abstractmethod from typing import Dict, Optional, Tuple, List, Union, Generator, Any, Type +from .connection import TcpClientConnection from .event import EventQueue, eventNames from ..common.flags import Flags @@ -37,15 +38,12 @@ class ThreadlessWork(ABC): @abstractmethod def __init__( self, - fileno: int, - addr: Tuple[str, int], + client: TcpClientConnection, flags: Optional[Flags], event_queue: Optional[EventQueue] = None, uid: Optional[str] = None) -> None: - self.fileno = fileno - self.addr = addr + self.client = client self.flags = flags if flags else Flags() - self.event_queue = event_queue self.uid: str = uid if uid is not None else uuid.uuid4().hex @@ -167,12 +165,16 @@ async def wait_for_tasks( except asyncio.TimeoutError: self.cleanup(work_id) + def fromfd(self, fileno: int) -> socket.socket: + return socket.fromfd( + fileno, family=socket.AF_INET if self.flags.hostname.version == 4 else socket.AF_INET6, + type=socket.SOCK_STREAM) + def accept_client(self) -> None: addr = self.client_queue.recv() fileno = recv_handle(self.client_queue) self.works[fileno] = self.work_klass( - fileno=fileno, - addr=addr, + TcpClientConnection(conn=self.fromfd(fileno), addr=addr), flags=self.flags, event_queue=self.event_queue ) diff --git a/proxy/http/handler.py b/proxy/http/handler.py index 569a23c929..a19a2b65d2 100644 --- a/proxy/http/handler.py +++ b/proxy/http/handler.py @@ -113,20 +113,18 @@ class HttpProtocolHandler(ThreadlessWork): Accepts `Client` connection object and manages HttpProtocolHandlerPlugin invocations. """ - def __init__(self, fileno: int, addr: Tuple[str, int], + def __init__(self, client: TcpClientConnection, flags: Optional[Flags] = None, event_queue: Optional[EventQueue] = None, uid: Optional[str] = None): - super().__init__(fileno, addr, flags, event_queue, uid) + super().__init__(client, flags, event_queue, uid) self.start_time: float = time.time() self.last_activity: float = self.start_time self.request: HttpParser = HttpParser(httpParserTypes.REQUEST_PARSER) self.response: HttpParser = HttpParser(httpParserTypes.RESPONSE_PARSER) self.selector = selectors.DefaultSelector() - self.client: TcpClientConnection = TcpClientConnection( - self.fromfd(self.fileno), self.addr - ) + self.client: TcpClientConnection = client self.plugins: Dict[str, HttpProtocolHandlerPlugin] = {} def initialize(self) -> None: @@ -134,7 +132,7 @@ def initialize(self) -> None: conn = self.optionally_wrap_socket(self.client.connection) conn.setblocking(False) if self.flags.encryption_enabled(): - self.client = TcpClientConnection(conn=conn, addr=self.addr) + self.client = TcpClientConnection(conn=conn, addr=self.client.addr) if b'HttpProtocolHandlerPlugin' in self.flags.plugins: for klass in self.flags.plugins[b'HttpProtocolHandlerPlugin']: instance = klass( @@ -232,12 +230,6 @@ def shutdown(self) -> None: logger.debug('Client connection closed') super().shutdown() - def fromfd(self, fileno: int) -> socket.socket: - conn = socket.fromfd( - fileno, family=socket.AF_INET if self.flags.hostname.version == 4 else socket.AF_INET6, - type=socket.SOCK_STREAM) - return conn - def optionally_wrap_socket( self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]: """Attempts to wrap accepted client connection using provided certificates. diff --git a/proxy/http/proxy/server.py b/proxy/http/proxy/server.py index 14b475f818..229187cd0c 100644 --- a/proxy/http/proxy/server.py +++ b/proxy/http/proxy/server.py @@ -106,7 +106,9 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: raw = self.server.recv(self.flags.server_recvbuf_size) except TimeoutError as e: if e.errno == errno.ETIMEDOUT: - logger.warning('%s:%d timed out on recv' % self.server.addr) + logger.warning( + '%s:%d timed out on recv' % + self.server.addr) return True else: raise e @@ -115,7 +117,9 @@ def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool: return False except OSError as e: if e.errno == errno.EHOSTUNREACH: - logger.warning('%s:%d unreachable on recv' % self.server.addr) + logger.warning( + '%s:%d unreachable on recv' % + self.server.addr) return True elif e.errno == errno.ECONNRESET: logger.warning('Connection reset by upstream: %r' % e) diff --git a/tests/core/test_acceptor.py b/tests/core/test_acceptor.py index 92ae2fb66e..537339d537 100644 --- a/tests/core/test_acceptor.py +++ b/tests/core/test_acceptor.py @@ -33,7 +33,7 @@ def setUp(self) -> None: @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - @mock.patch('proxy.core.acceptor.recv_handle') + @mock.patch('proxy.core.acceptor.acceptor.recv_handle') def test_continues_when_no_events( self, mock_recv_handle: mock.Mock, @@ -54,16 +54,18 @@ def test_continues_when_no_events( sock.accept.assert_not_called() self.mock_protocol_handler.assert_not_called() + @mock.patch('proxy.core.acceptor.acceptor.TcpClientConnection') @mock.patch('threading.Thread') @mock.patch('selectors.DefaultSelector') @mock.patch('socket.fromfd') - @mock.patch('proxy.core.acceptor.recv_handle') + @mock.patch('proxy.core.acceptor.acceptor.recv_handle') def test_accepts_client_from_server_socket( self, mock_recv_handle: mock.Mock, mock_fromfd: mock.Mock, mock_selector: mock.Mock, - mock_thread: mock.Mock) -> None: + mock_thread: mock.Mock, + mock_client: mock.Mock) -> None: fileno = 10 conn = mock.MagicMock() addr = mock.MagicMock() @@ -87,8 +89,7 @@ def test_accepts_client_from_server_socket( type=socket.SOCK_STREAM ) self.mock_protocol_handler.assert_called_with( - fileno=conn.fileno(), - addr=addr, + mock_client.return_value, flags=self.flags, event_queue=None, ) diff --git a/tests/core/test_acceptor_pool.py b/tests/core/test_acceptor_pool.py index 51f10d6095..e8192495ed 100644 --- a/tests/core/test_acceptor_pool.py +++ b/tests/core/test_acceptor_pool.py @@ -18,49 +18,50 @@ class TestAcceptorPool(unittest.TestCase): - @mock.patch('proxy.core.acceptor.send_handle') + @mock.patch('proxy.core.acceptor.pool.send_handle') @mock.patch('multiprocessing.Pipe') @mock.patch('socket.socket') - @mock.patch('proxy.core.acceptor.Acceptor') + @mock.patch('proxy.core.acceptor.pool.Acceptor') def test_setup_and_shutdown( self, - mock_worker: mock.Mock, + mock_acceptor: mock.Mock, mock_socket: mock.Mock, mock_pipe: mock.Mock, - _mock_send_handle: mock.Mock) -> None: - mock_worker1 = mock.MagicMock() - mock_worker2 = mock.MagicMock() - mock_worker.side_effect = [mock_worker1, mock_worker2] + mock_send_handle: mock.Mock) -> None: + acceptor1 = mock.MagicMock() + acceptor2 = mock.MagicMock() + mock_acceptor.side_effect = [acceptor1, acceptor2] num_workers = 2 sock = mock_socket.return_value work_klass = mock.MagicMock() flags = Flags(num_workers=2) - acceptor = AcceptorPool(flags=flags, work_klass=work_klass) - acceptor.setup() + pool = AcceptorPool(flags=flags, work_klass=work_klass) + pool.setup() + mock_send_handle.assert_called() work_klass.assert_not_called() mock_socket.assert_called_with( - socket.AF_INET6 if acceptor.flags.hostname.version == 6 else socket.AF_INET, + socket.AF_INET6 if pool.flags.hostname.version == 6 else socket.AF_INET, socket.SOCK_STREAM ) sock.setsockopt.assert_called_with( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind.assert_called_with( - (str(acceptor.flags.hostname), acceptor.flags.port)) - sock.listen.assert_called_with(acceptor.flags.backlog) + (str(pool.flags.hostname), pool.flags.port)) + sock.listen.assert_called_with(pool.flags.backlog) sock.setblocking.assert_called_with(False) self.assertTrue(mock_pipe.call_count, num_workers) - self.assertTrue(mock_worker.call_count, num_workers) - mock_worker1.start.assert_called() - mock_worker1.join.assert_not_called() - mock_worker2.start.assert_called() - mock_worker2.join.assert_not_called() + self.assertTrue(mock_acceptor.call_count, num_workers) + acceptor1.start.assert_called() + acceptor2.start.assert_called() + acceptor1.join.assert_not_called() + acceptor2.join.assert_not_called() sock.close.assert_called() - acceptor.shutdown() - mock_worker1.join.assert_called() - mock_worker2.join.assert_called() + pool.shutdown() + acceptor1.join.assert_called() + acceptor2.join.assert_called() diff --git a/tests/http/test_http_proxy.py b/tests/http/test_http_proxy.py index 3bb2648ae4..60024d3f00 100644 --- a/tests/http/test_http_proxy.py +++ b/tests/http/test_http_proxy.py @@ -14,6 +14,7 @@ from proxy.common.constants import DEFAULT_HTTP_PORT from proxy.common.flags import Flags +from proxy.core.connection import TcpClientConnection from proxy.http.proxy import HttpProxyPlugin from proxy.http.handler import HttpProtocolHandler from proxy.http.exception import HttpProtocolException @@ -40,7 +41,8 @@ def setUp(self, } self._conn = mock_fromfd.return_value self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) + TcpClientConnection(self._conn, self._addr), + flags=self.flags) self.protocol_handler.initialize() def test_proxy_plugin_initialized(self) -> None: diff --git a/tests/http/test_http_proxy_tls_interception.py b/tests/http/test_http_proxy_tls_interception.py index 87c3eed780..ff01a7c85f 100644 --- a/tests/http/test_http_proxy_tls_interception.py +++ b/tests/http/test_http_proxy_tls_interception.py @@ -17,6 +17,7 @@ from typing import Any from unittest import mock +from proxy.core.connection import TcpClientConnection from proxy.http.handler import HttpProtocolHandler from proxy.http.proxy import HttpProxyPlugin from proxy.http.methods import httpMethods @@ -78,7 +79,8 @@ def mock_connection() -> Any: } self._conn = mock_fromfd.return_value self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) + TcpClientConnection(self._conn, self._addr), + flags=self.flags) self.protocol_handler.initialize() self.plugin.assert_called() diff --git a/tests/http/test_protocol_handler.py b/tests/http/test_protocol_handler.py index f4140fe998..ca4dac037d 100644 --- a/tests/http/test_protocol_handler.py +++ b/tests/http/test_protocol_handler.py @@ -15,15 +15,16 @@ from typing import cast from unittest import mock +from proxy.common.version import __version__ from proxy.common.flags import Flags from proxy.common.utils import bytes_ from proxy.common.constants import CRLF +from proxy.core.connection import TcpClientConnection from proxy.http.parser import HttpParser from proxy.http.proxy import HttpProxyPlugin from proxy.http.parser import httpParserStates, httpParserTypes from proxy.http.exception import ProxyAuthenticationFailed, ProxyConnectionFailed from proxy.http.handler import HttpProtocolHandler -from proxy.common.version import __version__ class TestHttpProtocolHandler(unittest.TestCase): @@ -44,7 +45,7 @@ def setUp(self, self.mock_selector = mock_selector self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) + TcpClientConnection(self._conn, self._addr), flags=self.flags) self.protocol_handler.initialize() @mock.patch('proxy.http.proxy.server.TcpServerConnection') @@ -175,7 +176,7 @@ def test_proxy_authentication_failed( flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), flags=flags) self.protocol_handler.initialize() self._conn.recv.return_value = CRLF.join([ b'GET http://abhinavsingh.com HTTP/1.1', @@ -208,7 +209,7 @@ def test_authenticated_proxy_http_get( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, addr=self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), flags=flags) self.protocol_handler.initialize() assert self.http_server_port is not None @@ -256,7 +257,7 @@ def test_authenticated_proxy_http_tunnel( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), flags=flags) self.protocol_handler.initialize() assert self.http_server_port is not None diff --git a/tests/http/test_web_server.py b/tests/http/test_web_server.py index 72042b40a9..3a21fc9e1f 100644 --- a/tests/http/test_web_server.py +++ b/tests/http/test_web_server.py @@ -16,6 +16,7 @@ from unittest import mock from proxy.common.flags import Flags +from proxy.core.connection import TcpClientConnection from proxy.http.handler import HttpProtocolHandler from proxy.http.parser import httpParserStates from proxy.common.utils import build_http_response, build_http_request, bytes_, text_ @@ -36,7 +37,8 @@ def setUp(self, mock_fromfd: mock.Mock, mock_selector: mock.Mock) -> None: self.flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) + TcpClientConnection(self._conn, self._addr), + flags=self.flags) self.protocol_handler.initialize() @mock.patch('selectors.DefaultSelector') @@ -96,7 +98,8 @@ def test_default_web_server_returns_404( flags.plugins = Flags.load_plugins( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), + flags=flags) self.protocol_handler.initialize() self._conn.recv.return_value = CRLF.join([ b'GET /hello HTTP/1.1', @@ -147,7 +150,8 @@ def test_static_web_server_serves( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), + flags=flags) self.protocol_handler.initialize() self.protocol_handler.run_once() @@ -194,7 +198,8 @@ def test_static_web_server_serves_404( b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), + flags=flags) self.protocol_handler.initialize() self.protocol_handler.run_once() @@ -213,7 +218,8 @@ def test_on_client_connection_called_on_teardown( flags.plugins = {b'HttpProtocolHandlerPlugin': [plugin]} self._conn = mock_fromfd.return_value self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), + flags=flags) self.protocol_handler.initialize() plugin.assert_called() with mock.patch.object(self.protocol_handler, 'run_once') as mock_run_once: @@ -228,7 +234,8 @@ def init_and_make_pac_file_request(self, pac_file: str) -> None: b'proxy.http.proxy.HttpProxyPlugin,proxy.http.server.HttpWebServerPlugin,' b'proxy.http.server.HttpWebServerPacFilePlugin') self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=flags) + TcpClientConnection(self._conn, self._addr), + flags=flags) self.protocol_handler.initialize() self._conn.recv.return_value = CRLF.join([ b'GET / HTTP/1.1', diff --git a/tests/plugin/test_http_proxy_plugins.py b/tests/plugin/test_http_proxy_plugins.py index a768c1013e..84ca5a6970 100644 --- a/tests/plugin/test_http_proxy_plugins.py +++ b/tests/plugin/test_http_proxy_plugins.py @@ -17,6 +17,7 @@ from typing import cast from proxy.common.flags import Flags +from proxy.core.connection import TcpClientConnection from proxy.http.handler import HttpProtocolHandler from proxy.http.proxy import HttpProxyPlugin from proxy.common.utils import build_http_request, bytes_, build_http_response @@ -51,7 +52,8 @@ def setUp(self, } self._conn = mock_fromfd.return_value self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) + TcpClientConnection(self._conn, self._addr), + flags=self.flags) self.protocol_handler.initialize() @mock.patch('proxy.http.proxy.server.TcpServerConnection') diff --git a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py index ad05b2b1a3..2976869d97 100644 --- a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py +++ b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py @@ -19,6 +19,7 @@ from proxy.common.utils import bytes_ from proxy.common.flags import Flags from proxy.common.utils import build_http_request, build_http_response +from proxy.core.connection import TcpClientConnection from proxy.http.codes import httpStatusCodes from proxy.http.methods import httpMethods from proxy.http.handler import HttpProtocolHandler @@ -66,7 +67,7 @@ def setUp(self, self._conn = mock.MagicMock(spec=socket.socket) mock_fromfd.return_value = self._conn self.protocol_handler = HttpProtocolHandler( - self.fileno, self._addr, flags=self.flags) + TcpClientConnection(self._conn, self._addr), flags=self.flags) self.protocol_handler.initialize() self.server = self.mock_server_conn.return_value From ccaf868921d96893da532ab2795813890fa595f2 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 2 Dec 2019 19:18:43 -0800 Subject: [PATCH 074/107] Response parser now reaches COMPLETE even when no body is expected (#220) * Stash current changes * Refactor into connection module * Response parser state complete when no body expect * Raise NotImplementedError if invalid state reached within parser --- proxy/core/connection/__init__.py | 21 ++++++++++++ proxy/core/connection/client.py | 32 +++++++++++++++++ proxy/core/{ => connection}/connection.py | 42 ++--------------------- proxy/core/connection/server.py | 36 +++++++++++++++++++ proxy/core/ssh/__init__.py | 10 ++++++ proxy/core/ssh/client.py | 28 +++++++++++++++ proxy/core/{ => ssh}/tunnel.py | 7 ++-- proxy/http/parser.py | 29 +++++----------- tests/core/test_connection.py | 4 +-- tests/http/test_http_parser.py | 18 ++++++++-- 10 files changed, 158 insertions(+), 69 deletions(-) create mode 100644 proxy/core/connection/__init__.py create mode 100644 proxy/core/connection/client.py rename proxy/core/{ => connection}/connection.py (65%) create mode 100644 proxy/core/connection/server.py create mode 100644 proxy/core/ssh/__init__.py create mode 100644 proxy/core/ssh/client.py rename proxy/core/{ => ssh}/tunnel.py (89%) diff --git a/proxy/core/connection/__init__.py b/proxy/core/connection/__init__.py new file mode 100644 index 0000000000..ee44bc14a6 --- /dev/null +++ b/proxy/core/connection/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .connection import TcpConnection, TcpConnectionUninitializedException, tcpConnectionTypes +from .client import TcpClientConnection +from .server import TcpServerConnection + +__all__ = [ + 'TcpConnection', + 'TcpConnectionUninitializedException', + 'TcpServerConnection', + 'TcpClientConnection', + 'tcpConnectionTypes', +] diff --git a/proxy/core/connection/client.py b/proxy/core/connection/client.py new file mode 100644 index 0000000000..28995a58a7 --- /dev/null +++ b/proxy/core/connection/client.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +import ssl +from typing import Union, Tuple, Optional + +from .connection import TcpConnection, tcpConnectionTypes, TcpConnectionUninitializedException + + +class TcpClientConnection(TcpConnection): + """An accepted client connection request.""" + + def __init__(self, + conn: Union[ssl.SSLSocket, socket.socket], + addr: Tuple[str, int]): + super().__init__(tcpConnectionTypes.CLIENT) + self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = conn + self.addr: Tuple[str, int] = addr + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + if self._conn is None: + raise TcpConnectionUninitializedException() + return self._conn diff --git a/proxy/core/connection.py b/proxy/core/connection/connection.py similarity index 65% rename from proxy/core/connection.py rename to proxy/core/connection/connection.py index 7b5fe6c953..29c15c1e34 100644 --- a/proxy/core/connection.py +++ b/proxy/core/connection/connection.py @@ -12,10 +12,9 @@ import ssl import logging from abc import ABC, abstractmethod -from typing import NamedTuple, Optional, Union, Tuple, List +from typing import NamedTuple, Optional, Union, List -from ..common.constants import DEFAULT_BUFFER_SIZE -from ..common.utils import new_socket_connection +from ...common.constants import DEFAULT_BUFFER_SIZE logger = logging.getLogger(__name__) @@ -91,40 +90,3 @@ def flush(self) -> int: self.buffer[0] = memoryview(mv.tobytes()[sent:]) logger.debug('flushed %d bytes to %s' % (sent, self.tag)) return sent - - -class TcpServerConnection(TcpConnection): - """Establishes connection to upstream server.""" - - def __init__(self, host: str, port: int): - super().__init__(tcpConnectionTypes.SERVER) - self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None - self.addr: Tuple[str, int] = (host, int(port)) - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - if self._conn is None: - raise TcpConnectionUninitializedException() - return self._conn - - def connect(self) -> None: - if self._conn is not None: - return - self._conn = new_socket_connection(self.addr) - - -class TcpClientConnection(TcpConnection): - """An accepted client connection request.""" - - def __init__(self, - conn: Union[ssl.SSLSocket, socket.socket], - addr: Tuple[str, int]): - super().__init__(tcpConnectionTypes.CLIENT) - self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = conn - self.addr: Tuple[str, int] = addr - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - if self._conn is None: - raise TcpConnectionUninitializedException() - return self._conn diff --git a/proxy/core/connection/server.py b/proxy/core/connection/server.py new file mode 100644 index 0000000000..cbb9806a92 --- /dev/null +++ b/proxy/core/connection/server.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +import ssl +from typing import Optional, Union, Tuple + +from .connection import TcpConnection, tcpConnectionTypes, TcpConnectionUninitializedException +from ...common.utils import new_socket_connection + + +class TcpServerConnection(TcpConnection): + """Establishes connection to upstream server.""" + + def __init__(self, host: str, port: int): + super().__init__(tcpConnectionTypes.SERVER) + self._conn: Optional[Union[ssl.SSLSocket, socket.socket]] = None + self.addr: Tuple[str, int] = (host, int(port)) + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + if self._conn is None: + raise TcpConnectionUninitializedException() + return self._conn + + def connect(self) -> None: + if self._conn is not None: + return + self._conn = new_socket_connection(self.addr) diff --git a/proxy/core/ssh/__init__.py b/proxy/core/ssh/__init__.py new file mode 100644 index 0000000000..232621f0b5 --- /dev/null +++ b/proxy/core/ssh/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" diff --git a/proxy/core/ssh/client.py b/proxy/core/ssh/client.py new file mode 100644 index 0000000000..650d894809 --- /dev/null +++ b/proxy/core/ssh/client.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import socket +import ssl +from typing import Union + +from ..connection import TcpClientConnection + + +class SshClient(TcpClientConnection): + """Overrides TcpClientConnection. + + This is necessary because paramiko fileno() can be used for polling + but not for send / recv. + """ + + @property + def connection(self) -> Union[ssl.SSLSocket, socket.socket]: + # Dummy return to comply with + return socket.socket() diff --git a/proxy/core/tunnel.py b/proxy/core/ssh/tunnel.py similarity index 89% rename from proxy/core/tunnel.py rename to proxy/core/ssh/tunnel.py index 612fb52d45..e3a61b54df 100644 --- a/proxy/core/tunnel.py +++ b/proxy/core/ssh/tunnel.py @@ -8,7 +8,6 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -import socket from typing import Tuple, Callable import paramiko @@ -26,7 +25,7 @@ def __init__( remote_addr: Tuple[str, int], private_pem_key: str, remote_proxy_port: int, - conn_handler: Callable[[socket.socket], None]) -> None: + conn_handler: Callable[[paramiko.channel.Channel], None]) -> None: self.remote_addr = remote_addr self.ssh_username = ssh_username self.private_pem_key = private_pem_key @@ -45,11 +44,11 @@ def run(self) -> None: key_filename=self.private_pem_key ) print('SSH connection established...') - transport = ssh.get_transport() + transport: paramiko.transport.Transport = ssh.get_transport() transport.request_port_forward('', self.remote_proxy_port) print('Tunnel port forward setup successful...') while True: - conn: socket.socket = transport.accept(timeout=1) + conn: paramiko.channel.Channel = transport.accept(timeout=1) e = transport.get_exception() if e: raise e diff --git a/proxy/http/parser.py b/proxy/http/parser.py index 80945b5d5c..ece1ce0861 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -126,6 +126,11 @@ def is_chunked_encoded(self) -> bool: return b'transfer-encoding' in self.headers and \ self.headers[b'transfer-encoding'][1].lower() == b'chunked' + def body_expected(self) -> bool: + return (b'content-length' in self.headers and + int(self.header(b'content-length')) > 0) or \ + self.is_chunked_encoded() + def parse(self, raw: bytes) -> None: """Parses Http request out of raw bytes. @@ -134,7 +139,6 @@ def parse(self, raw: bytes) -> None: raw = self.buffer + raw self.buffer = b'' - # TODO(abhinavsingh): Someday clean this up. more = True if len(raw) > 0 else False while more and self.state != httpParserStates.COMPLETE: if self.state in ( @@ -159,6 +163,8 @@ def parse(self, raw: bytes) -> None: self.body = self.chunk_parser.body self.state = httpParserStates.COMPLETE more = False + else: + raise NotImplementedError('Parser shouldn\'t have reached here') else: more, raw = self.process(raw) self.buffer = raw @@ -181,33 +187,14 @@ def process(self, raw: bytes) -> Tuple[bool, bytes]: else: self.process_header(line) - # TODO(abhinavsingh): Can these be generalized instead of per-case handling? # When server sends a response line without any header or body e.g. # HTTP/1.1 200 Connection established\r\n\r\n if self.state == httpParserStates.LINE_RCVD and \ self.type == httpParserTypes.RESPONSE_PARSER and \ raw == CRLF: self.state = httpParserStates.COMPLETE - # When connect request is received without a following host header - # See - # `TestHttpParser.test_connect_request_without_host_header_request_parse` - # for details - # - # When raw request has ended with \r\n\r\n and no more http headers are expected - # See `TestHttpParser.test_request_parse_without_content_length` and - # `TestHttpParser.test_response_parse_without_content_length` for details - elif self.state == httpParserStates.HEADERS_COMPLETE and \ - self.type == httpParserTypes.REQUEST_PARSER and \ - self.method != httpMethods.POST and \ - raw == b'': - self.state = httpParserStates.COMPLETE elif self.state == httpParserStates.HEADERS_COMPLETE and \ - self.type == httpParserTypes.REQUEST_PARSER and \ - self.method == httpMethods.POST and \ - not self.is_chunked_encoded() and \ - (b'content-length' not in self.headers or - (b'content-length' in self.headers and - int(self.headers[b'content-length'][1]) == 0)) and \ + not self.body_expected() and \ raw == b'': self.state = httpParserStates.COMPLETE diff --git a/tests/core/test_connection.py b/tests/core/test_connection.py index 6808a465da..3cd63ad129 100644 --- a/tests/core/test_connection.py +++ b/tests/core/test_connection.py @@ -73,7 +73,7 @@ def testTcpServerEstablishesIPv6Connection( mock_socket.return_value.connect.assert_called_with( (str(DEFAULT_IPV6_HOSTNAME), DEFAULT_PORT, 0, 0)) - @mock.patch('proxy.core.connection.new_socket_connection') + @mock.patch('proxy.core.connection.server.new_socket_connection') def testTcpServerIgnoresDoubleConnectSilently( self, mock_new_socket_connection: mock.Mock) -> None: @@ -93,7 +93,7 @@ def testTcpServerEstablishesIPv4Connection( mock_socket.return_value.connect.assert_called_with( (str(DEFAULT_IPV4_HOSTNAME), DEFAULT_PORT)) - @mock.patch('proxy.core.connection.new_socket_connection') + @mock.patch('proxy.core.connection.server.new_socket_connection') def testTcpServerConnectionProperty( self, mock_new_socket_connection: mock.Mock) -> None: diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index 623af08df1..ee13621125 100644 --- a/tests/http/test_http_parser.py +++ b/tests/http/test_http_parser.py @@ -329,7 +329,12 @@ def test_response_parse_without_content_length(self) -> None: and it is responsibility of callee to change state to httpParserStates.COMPLETE when server stream closes. - See https://github.com/abhinavsingh/py/issues/20 for details. + See https://github.com/abhinavsingh/proxy.py/issues/20 for details. + + Post commit https://github.com/abhinavsingh/proxy.py/commit/269484df2e89bc659124177d339d4fc59f280cba + HttpParser would reach state COMPLETE also for RESPONSE_PARSER types and no longer + it is callee responsibility to change state on stream close. This was important because + pipelined responses not trigger stream close but may receive multiple responses. """ self.parser.type = httpParserTypes.RESPONSE_PARSER self.parser.parse(b'HTTP/1.0 200 OK' + CRLF) @@ -343,7 +348,7 @@ def test_response_parse_without_content_length(self) -> None: ])) self.assertEqual( self.parser.state, - httpParserStates.HEADERS_COMPLETE) + httpParserStates.COMPLETE) def test_response_parse(self) -> None: self.parser.type = httpParserTypes.RESPONSE_PARSER @@ -513,3 +518,12 @@ def test_is_not_http_1_1_keep_alive_for_http_1_0(self) -> None: httpMethods.GET, b'/', protocol_version=b'HTTP/1.0', )) self.assertFalse(self.parser.is_http_1_1_keep_alive()) + + def test_paramiko_doc(self) -> None: + response = b'HTTP/1.1 304 Not Modified\r\nDate: Tue, 03 Dec 2019 02:31:55 GMT\r\nConnection: keep-alive' \ + b'\r\nLast-Modified: Sun, 23 Jun 2019 22:58:21 GMT\r\nETag: "5d10040d-1af2c"' \ + b'\r\nX-Cname-TryFiles: True\r\nX-Served: Nginx\r\nX-Deity: web02\r\nCF-Cache-Status: DYNAMIC' \ + b'\r\nServer: cloudflare\r\nCF-RAY: 53f2208c6fef6c38-SJC\r\n\r\n' + self.parser = HttpParser(httpParserTypes.RESPONSE_PARSER) + self.parser.parse(response) + self.assertEqual(self.parser.state, httpParserStates.COMPLETE) From 169d4901c30506004387bb5b092ca2819f87baf7 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 3 Dec 2019 21:10:17 +0200 Subject: [PATCH 075/107] Update tox from 3.14.1 to 3.14.2 (#221) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 58fb0e2b00..772f74928d 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -7,5 +7,5 @@ autopep8==1.4.4 mypy==0.750 py-spy==0.3.0 codecov==2.0.15 -tox==3.14.1 +tox==3.14.2 mccabe==0.6.1 From 2f21dea9578a7b854c62db5b85363a16fb1c774b Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 4 Dec 2019 20:50:51 +0200 Subject: [PATCH 076/107] Update paramiko from 2.6.0 to 2.7.0 (#225) --- requirements-tunnel.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-tunnel.txt b/requirements-tunnel.txt index 6d6a415206..bfbc490055 100644 --- a/requirements-tunnel.txt +++ b/requirements-tunnel.txt @@ -1 +1 @@ -paramiko==2.6.0 +paramiko==2.7.0 From e9dfc53f2c074ed627b96d96bda9cb85799e9cba Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 10 Dec 2019 02:45:23 +0200 Subject: [PATCH 077/107] Update paramiko from 2.7.0 to 2.7.1 (#227) --- requirements-tunnel.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-tunnel.txt b/requirements-tunnel.txt index bfbc490055..ada4a36595 100644 --- a/requirements-tunnel.txt +++ b/requirements-tunnel.txt @@ -1 +1 @@ -paramiko==2.7.0 +paramiko==2.7.1 From ba5c84dfbdda4c985787f77558283e82490eddcc Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 9 Dec 2019 19:38:49 -0800 Subject: [PATCH 078/107] Proxy Pool Plugin (#228) * Add proxy pool example. See #226 * Add ProxyPoolPlugin to doc --- README.md | 48 ++++++++++++++++++---- proxy/http/proxy/plugin.py | 2 + proxy/plugin/__init__.py | 2 + proxy/plugin/proxy_pool.py | 84 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 proxy/plugin/proxy_pool.py diff --git a/README.md b/README.md index dd13814a05..9344e691d7 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,14 @@ Table of Contents * [Customize Startup Flags](#customize-startup-flags) * [Plugin Examples](#plugin-examples) * [HTTP Proxy Plugins](#http-proxy-plugins) - * [ShortLinkPlugin](#shortlinkplugin) - * [ModifyPostDataPlugin](#modifypostdataplugin) - * [MockRestApiPlugin](#mockrestapiplugin) - * [RedirectToCustomServerPlugin](#redirecttocustomserverplugin) - * [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin) - * [CacheResponsesPlugin](#cacheresponsesplugin) - * [ManInTheMiddlePlugin](#maninthemiddleplugin) + * [ShortLink Plugin](#shortlinkplugin) + * [Modify Post Data Plugin](#modifypostdataplugin) + * [Mock Api Plugin](#mockrestapiplugin) + * [Redirect To Custom Server Plugin](#redirecttocustomserverplugin) + * [Filter By Upstream Host Plugin](#filterbyupstreamhostplugin) + * [Cache Responses Plugin](#cacheresponsesplugin) + * [Man-In-The-Middle Plugin](#maninthemiddleplugin) + * [Proxy Pool Plugin](#proxypoolplugin) * [HTTP Web Server Plugins](#http-web-server-plugins) * [Reverse Proxy](#reverse-proxy) * [Web Server Route](#web-server-route) @@ -615,6 +616,39 @@ Hello from man in the middle Response body `Hello from man in the middle` is sent by our plugin. +### ProxyPoolPlugin + +Forward incoming proxy requests to a set of upstream proxy servers. + +By default, `ProxyPoolPlugin` is hard-coded to use +`localhost:9000` and `localhost:9001` as upstream proxy server. + +Let's start upstream proxies first. + +Start `proxy.py` on port `9000` and `9001` + +``` +$ proxy --port 9000 +``` + +``` +$ proxy --port 9001 +``` + +Now, start `proxy.py` with `ProxyPoolPlugin` (on default `8899` port): + +``` +$ proxy \ + --plugins proxy.plugin.ProxyPoolPlugin +``` + +Make a curl request via `8899` proxy: + +`curl -v -x localhost:8899 http://httpbin.org/get` + +Verify that `8899` proxy forwards requests to upstream proxies +by checking respective logs. + ## HTTP Web Server Plugins ### Reverse Proxy diff --git a/proxy/http/proxy/plugin.py b/proxy/http/proxy/plugin.py index 75b5c9b803..63fda08f8d 100644 --- a/proxy/http/proxy/plugin.py +++ b/proxy/http/proxy/plugin.py @@ -48,6 +48,8 @@ def before_upstream_connection( """Handler called just before Proxy upstream connection is established. Return optionally modified request object. + If None is returned, upstream connection won't be established. + Raise HttpRequestRejected or HttpProtocolException directly to drop the connection.""" return request # pragma: no cover diff --git a/proxy/plugin/__init__.py b/proxy/plugin/__init__.py index b31a4567e2..1ef0af91fe 100644 --- a/proxy/plugin/__init__.py +++ b/proxy/plugin/__init__.py @@ -17,6 +17,7 @@ from .shortlink import ShortLinkPlugin from .web_server_route import WebServerPlugin from .reverse_proxy import ReverseProxyPlugin +from .proxy_pool import ProxyPoolPlugin __all__ = [ 'CacheResponsesPlugin', @@ -29,4 +30,5 @@ 'ShortLinkPlugin', 'WebServerPlugin', 'ReverseProxyPlugin', + 'ProxyPoolPlugin', ] diff --git a/proxy/plugin/proxy_pool.py b/proxy/plugin/proxy_pool.py new file mode 100644 index 0000000000..e701fcb8f4 --- /dev/null +++ b/proxy/plugin/proxy_pool.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import random +import socket +from typing import Optional, Any + +from ..common.constants import DEFAULT_BUFFER_SIZE, SLASH, COLON +from ..common.utils import new_socket_connection +from ..http.proxy import HttpProxyBasePlugin +from ..http.parser import HttpParser + + +class ProxyPoolPlugin(HttpProxyBasePlugin): + """Proxy incoming client proxy requests through a set of upstream proxies.""" + + # Run two separate instances of proxy.py + # on port 9000 and 9001 BUT WITHOUT ProxyPool plugin + # to avoid infinite loops. + UPSTREAM_PROXY_POOL = [ + ('localhost', 9000), + ('localhost', 9001), + ] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.conn: Optional[socket.socket] = None + + def before_upstream_connection( + self, request: HttpParser) -> Optional[HttpParser]: + """Avoid upstream connection of server in the request. + Initialize, connection to upstream proxy. + """ + # Implement your own logic here e.g. round-robin, least connection etc. + self.conn = new_socket_connection( + random.choice(self.UPSTREAM_PROXY_POOL)) + return None + + def handle_client_request( + self, request: HttpParser) -> Optional[HttpParser]: + request.path = self.rebuild_original_path(request) + self.tunnel(request) + # Returning None indicates core to gracefully + # flush client buffer and teardown the connection + return None + + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: + """Will never be called since we didn't establish an upstream connection.""" + return chunk + + def on_upstream_connection_close(self) -> None: + """Will never be called since we didn't establish an upstream connection.""" + pass + + def tunnel(self, request: HttpParser) -> None: + """Send to upstream proxy, receive from upstream proxy, queue back to client.""" + assert self.conn + self.conn.send(request.build()) + response = self.conn.recv(DEFAULT_BUFFER_SIZE) + self.client.queue(memoryview(response)) + + @staticmethod + def rebuild_original_path(request: HttpParser) -> bytes: + """Re-builds original upstream server URL. + + proxy server core by default strips upstream host:port + from incoming client proxy request. + """ + assert request.url and request.host and request.port and request.path + return ( + request.url.scheme + + COLON + SLASH + SLASH + + request.host + + COLON + + bytes(request.port) + + request.path + ) From 59325aefc2c319fd879902761f5c1df703e6e863 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 14 Dec 2019 18:48:40 +0200 Subject: [PATCH 079/107] Update pytest from 5.3.1 to 5.3.2 (#229) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 772f74928d..ae2cec9108 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,7 +1,7 @@ python-coveralls==2.9.3 coverage==4.5.4 flake8==3.7.9 -pytest==5.3.1 +pytest==5.3.2 pytest-cov==2.8.1 autopep8==1.4.4 mypy==0.750 From fee3af978f99d906945f45d5fb916a9efe7c8813 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 14 Dec 2019 23:14:42 +0200 Subject: [PATCH 080/107] Update coverage from 4.5.4 to 5.0 (#230) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index ae2cec9108..882ba6acdf 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,5 +1,5 @@ python-coveralls==2.9.3 -coverage==4.5.4 +coverage==5.0 flake8==3.7.9 pytest==5.3.2 pytest-cov==2.8.1 From 71a747134c96d1fc542a17c3299c558b31c760c6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 18 Dec 2019 17:44:30 +0200 Subject: [PATCH 081/107] Update mypy from 0.750 to 0.760 (#232) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 882ba6acdf..5868c4403f 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -4,7 +4,7 @@ flake8==3.7.9 pytest==5.3.2 pytest-cov==2.8.1 autopep8==1.4.4 -mypy==0.750 +mypy==0.760 py-spy==0.3.0 codecov==2.0.15 tox==3.14.2 From fdf0cc557f41380bcd274d36cf19642354708612 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 20 Dec 2019 23:10:52 +0200 Subject: [PATCH 082/107] Update mypy from 0.760 to 0.761 (#235) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 5868c4403f..ac523e608d 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -4,7 +4,7 @@ flake8==3.7.9 pytest==5.3.2 pytest-cov==2.8.1 autopep8==1.4.4 -mypy==0.760 +mypy==0.761 py-spy==0.3.0 codecov==2.0.15 tox==3.14.2 From 625dc4d3e57df2654cf0cec94d0a07b18f8a1a82 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 20 Dec 2019 14:01:26 -0800 Subject: [PATCH 083/107] Move manager initialization outside of top level scope. Fixes #233 (#236) --- .gitignore | 2 +- proxy/core/event.py | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 716dc8656f..404007f4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ proxy.py.iml *.crt *.key -venv +venv* cover htmlcov dist diff --git a/proxy/core/event.py b/proxy/core/event.py index a77ca804a5..cca9ce3f56 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -37,13 +37,29 @@ class EventQueue: - """Global event queue.""" - - MANAGER: multiprocessing.managers.SyncManager = multiprocessing.Manager() + """Global event queue. + + Each event contains: + + 1. Request ID - Globally unique + 2. Process ID - Process ID of event publisher. + This will be process id of acceptor workers. + 3. Thread ID - Thread ID of event publisher. + When --threadless is enabled, this value will + be same for all the requests + received by a single acceptor worker. + When --threadless is disabled, this value will be + Thread ID of the thread handling the client request. + 4. Event Timestamp - Time when this event occur + 5. Event Name - One of the defined or custom event name + 6. Event Payload - Optional data associated with the event + 7. Publisher ID (optional) - Optionally, publishing entity unique name / ID + """ def __init__(self) -> None: super().__init__() - self.queue = EventQueue.MANAGER.Queue() + self.manager = multiprocessing.Manager() + self.queue = self.manager.Queue() def publish( self, @@ -52,21 +68,6 @@ def publish( event_payload: Dict[str, Any], publisher_id: Optional[str] = None ) -> None: - """Publish an event into the queue. - - 1. Request ID - Globally unique - 2. Process ID - Process ID of event publisher. - This will be process id of acceptor workers. - 3. Thread ID - Thread ID of event publisher. - When --threadless is enabled, this value will be same for all the requests - received by a single acceptor worker. - When --threadless is disabled, this value will be - Thread ID of the thread handling the client request. - 4. Event Timestamp - Time when this event occur - 5. Event Name - One of the defined or custom event name - 6. Event Payload - Optional data associated with the event - 7. Publisher ID (optional) - Optionally, publishing entity unique name / ID - """ self.queue.put({ 'request_id': request_id, 'process_id': os.getpid(), @@ -169,9 +170,8 @@ def run(self) -> None: class EventSubscriber: """Core event subscriber.""" - MANAGER: multiprocessing.managers.SyncManager = multiprocessing.Manager() - def __init__(self, event_queue: EventQueue) -> None: + self.manager = multiprocessing.Manager() self.event_queue = event_queue self.relay_thread: Optional[threading.Thread] = None self.relay_shutdown: Optional[threading.Event] = None @@ -180,7 +180,7 @@ def __init__(self, event_queue: EventQueue) -> None: def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: self.relay_shutdown = threading.Event() - self.relay_channel = EventSubscriber.MANAGER.Queue() + self.relay_channel = self.manager.Queue() self.relay_thread = threading.Thread( target=self.relay, args=(self.relay_shutdown, self.relay_channel, callback)) From c6c09395f9d3f177d78049c48b522f29bb6ae32a Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 20 Dec 2019 15:13:46 -0800 Subject: [PATCH 084/107] Share lock to acceptors via pool (#238) * Move manager initialization outside of top level scope. Fixes #233 * Share lock to acceptor via pool --- proxy/core/acceptor/acceptor.py | 8 +++++--- proxy/core/acceptor/pool.py | 5 ++++- tests/core/test_acceptor.py | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/proxy/core/acceptor/acceptor.py b/proxy/core/acceptor/acceptor.py index 648f51edc5..eeb2955269 100644 --- a/proxy/core/acceptor/acceptor.py +++ b/proxy/core/acceptor/acceptor.py @@ -10,6 +10,7 @@ """ import logging import multiprocessing +import multiprocessing.synchronize import selectors import socket import threading @@ -33,20 +34,20 @@ class Acceptor(multiprocessing.Process): starts a new work thread. """ - lock = multiprocessing.Lock() - def __init__( self, idd: int, work_queue: connection.Connection, flags: Flags, work_klass: Type[ThreadlessWork], + lock: multiprocessing.synchronize.Lock, event_queue: Optional[EventQueue] = None) -> None: super().__init__() self.idd = idd self.work_queue: connection.Connection = work_queue self.flags = flags self.work_klass = work_klass + self.lock = lock self.event_queue = event_queue self.running = multiprocessing.Event() @@ -101,12 +102,13 @@ def start_work(self, conn: socket.socket, addr: Tuple[str, int]) -> None: work_thread.start() def run_once(self) -> None: - assert self.selector and self.sock with self.lock: + assert self.selector and self.sock events = self.selector.select(timeout=1) if len(events) == 0: return conn, addr = self.sock.accept() + # now = time.time() # fileno: int = conn.fileno() self.start_work(conn, addr) diff --git a/proxy/core/acceptor/pool.py b/proxy/core/acceptor/pool.py index 4b83b9ae95..87d7158f26 100644 --- a/proxy/core/acceptor/pool.py +++ b/proxy/core/acceptor/pool.py @@ -24,6 +24,8 @@ logger = logging.getLogger(__name__) +LOCK = multiprocessing.Lock() + class AcceptorPool: """AcceptorPool. @@ -66,7 +68,8 @@ def start_workers(self) -> None: work_queue=work_queue[1], flags=self.flags, work_klass=self.work_klass, - event_queue=self.event_queue + lock=LOCK, + event_queue=self.event_queue, ) acceptor.start() logger.debug( diff --git a/tests/core/test_acceptor.py b/tests/core/test_acceptor.py index 537339d537..2b4dbd9bc3 100644 --- a/tests/core/test_acceptor.py +++ b/tests/core/test_acceptor.py @@ -29,6 +29,7 @@ def setUp(self) -> None: idd=self.acceptor_id, work_queue=self.pipe[1], flags=self.flags, + lock=multiprocessing.Lock(), work_klass=self.mock_protocol_handler) @mock.patch('selectors.DefaultSelector') From 63e6d22566f7d114fc3a963a465e57bedc69eb25 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Fri, 20 Dec 2019 17:10:35 -0800 Subject: [PATCH 085/107] Optionally initialize manager in main thread and use the same for EventQueue initialization (#239) --- .gitignore | 8 ++++---- proxy/common/constants.py | 2 +- proxy/core/acceptor/pool.py | 5 ++++- proxy/core/event.py | 6 ++---- tests/core/test_event_dispatcher.py | 2 +- tests/core/test_event_queue.py | 8 +++++--- tests/core/test_event_subscriber.py | 3 ++- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 404007f4ab..45423f12d5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,16 +12,16 @@ coverage.xml proxy.py.iml -*.pyc +*.pyc +*.egg-info *.csr *.crt *.key - venv* + cover htmlcov dist build - -*.egg-info +proxy/public diff --git a/proxy/common/constants.py b/proxy/common/constants.py index d2ec48d7f5..72efbce57d 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -68,7 +68,7 @@ DEFAULT_PLUGINS = '' DEFAULT_PORT = 8899 DEFAULT_SERVER_RECVBUF_SIZE = DEFAULT_BUFFER_SIZE -DEFAULT_STATIC_SERVER_DIR = PROXY_PY_DIR +DEFAULT_STATIC_SERVER_DIR = os.path.join(PROXY_PY_DIR, "public") DEFAULT_THREADLESS = False DEFAULT_TIMEOUT = 10 DEFAULT_VERSION = False diff --git a/proxy/core/acceptor/pool.py b/proxy/core/acceptor/pool.py index 87d7158f26..48cedaf96f 100644 --- a/proxy/core/acceptor/pool.py +++ b/proxy/core/acceptor/pool.py @@ -46,8 +46,11 @@ def __init__(self, flags: Flags, work_klass: Type[ThreadlessWork]) -> None: self.event_dispatcher: Optional[EventDispatcher] = None self.event_dispatcher_thread: Optional[threading.Thread] = None self.event_dispatcher_shutdown: Optional[threading.Event] = None + self.manager: Optional[multiprocessing.managers.SyncManager] = None + if self.flags.enable_events: - self.event_queue = EventQueue() + self.manager = multiprocessing.Manager() + self.event_queue = EventQueue(self.manager.Queue()) def listen(self) -> None: self.socket = socket.socket(self.flags.family, socket.SOCK_STREAM) diff --git a/proxy/core/event.py b/proxy/core/event.py index cca9ce3f56..00fec14d96 100644 --- a/proxy/core/event.py +++ b/proxy/core/event.py @@ -56,10 +56,8 @@ class EventQueue: 7. Publisher ID (optional) - Optionally, publishing entity unique name / ID """ - def __init__(self) -> None: - super().__init__() - self.manager = multiprocessing.Manager() - self.queue = self.manager.Queue() + def __init__(self, queue: DictQueueType) -> None: + self.queue = queue def publish( self, diff --git a/tests/core/test_event_dispatcher.py b/tests/core/test_event_dispatcher.py index 72930b8e9c..bb17a709ba 100644 --- a/tests/core/test_event_dispatcher.py +++ b/tests/core/test_event_dispatcher.py @@ -24,7 +24,7 @@ class TestEventDispatcher(unittest.TestCase): def setUp(self) -> None: self.dispatcher_shutdown = threading.Event() - self.event_queue = EventQueue() + self.event_queue = EventQueue(multiprocessing.Manager().Queue()) self.dispatcher = EventDispatcher( shutdown=self.dispatcher_shutdown, event_queue=self.event_queue) diff --git a/tests/core/test_event_queue.py b/tests/core/test_event_queue.py index 09e57bc5ef..18f7527163 100644 --- a/tests/core/test_event_queue.py +++ b/tests/core/test_event_queue.py @@ -17,13 +17,15 @@ from proxy.core.event import EventQueue, eventNames +MANAGER = multiprocessing.Manager() + class TestCoreEvent(unittest.TestCase): @mock.patch('time.time') def test_publish(self, mock_time: mock.Mock) -> None: mock_time.return_value = 1234567 - evq = EventQueue() + evq = EventQueue(MANAGER.Queue()) evq.publish( request_id='1234', event_name=eventNames.WORK_STARTED, @@ -41,7 +43,7 @@ def test_publish(self, mock_time: mock.Mock) -> None: }) def test_subscribe(self) -> None: - evq = EventQueue() + evq = EventQueue(MANAGER.Queue()) q = multiprocessing.Manager().Queue() evq.subscribe('1234', q) ev = evq.queue.get() @@ -49,7 +51,7 @@ def test_subscribe(self) -> None: self.assertEqual(ev['event_payload']['sub_id'], '1234') def test_unsubscribe(self) -> None: - evq = EventQueue() + evq = EventQueue(MANAGER.Queue()) evq.unsubscribe('1234') ev = evq.queue.get() self.assertEqual(ev['event_name'], eventNames.UNSUBSCRIBE) diff --git a/tests/core/test_event_subscriber.py b/tests/core/test_event_subscriber.py index 902f2f302a..30e67b39df 100644 --- a/tests/core/test_event_subscriber.py +++ b/tests/core/test_event_subscriber.py @@ -11,6 +11,7 @@ import os import threading import unittest +import multiprocessing from typing import Dict, Any from unittest import mock @@ -26,7 +27,7 @@ class TestEventSubscriber(unittest.TestCase): def test_event_subscriber(self, mock_time: mock.Mock) -> None: mock_time.return_value = 1234567 self.dispatcher_shutdown = threading.Event() - self.event_queue = EventQueue() + self.event_queue = EventQueue(multiprocessing.Manager().Queue()) self.dispatcher = EventDispatcher( shutdown=self.dispatcher_shutdown, event_queue=self.event_queue) From 7f9385ed1cbcb2d9e53760ce0c9512cd67c0b19a Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Sat, 21 Dec 2019 20:57:29 -0800 Subject: [PATCH 086/107] Highlight language syntax (#240) * Highlight lang syntax * zsh prompt --- README.md | 322 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 168 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 9344e691d7..6b7107f105 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,9 @@ Features - Scales by using all available cores on the system - Threadless executions using coroutine - Made to handle `tens-of-thousands` connections / sec - ``` + ```bash # On Macbook Pro 2015 / 2.8 GHz Intel Core i7 - $ hey -n 10000 -c 100 http://localhost:8899/ + ❯ hey -n 10000 -c 100 http://localhost:8899/ Summary: Total: 0.6157 secs @@ -168,28 +168,38 @@ Install Install from `PyPi` - $ pip install --upgrade proxy.py +```bash +❯ pip install --upgrade proxy.py +``` or from GitHub `master` branch - $ pip install git+https://github.com/abhinavsingh/proxy.py.git@master +```bash +❯ pip install git+https://github.com/abhinavsingh/proxy.py.git@master +``` ### Development Version with PIP - $ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop +```bash +❯ pip install git+https://github.com/abhinavsingh/proxy.py.git@develop +``` ## Using Docker #### Stable Version from Docker Hub - $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest +```bash +❯ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest +``` #### Build Development Version Locally - $ git clone https://github.com/abhinavsingh/proxy.py.git - $ cd proxy.py - $ make container - $ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest +```bash +❯ git clone https://github.com/abhinavsingh/proxy.py.git +❯ cd proxy.py +❯ make container +❯ docker run -it -p 8899:8899 --rm abhinavsingh/proxy.py:latest +``` [![WARNING](https://img.shields.io/static/v1?label=MacOS&message=warning&color=red)](https://github.com/moby/vpnkit/issues/469) `docker` image is currently broken on `macOS` due to incompatibility with [vpnkit](https://github.com/moby/vpnkit/issues/469). @@ -198,11 +208,15 @@ or from GitHub `master` branch ### Stable Version with HomeBrew - $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/helper/homebrew/stable/proxy.rb +```bash +❯ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/helper/homebrew/stable/proxy.rb +``` ### Development Version with HomeBrew - $ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/helper/homebrew/develop/proxy.rb +```bash +❯ brew install https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/helper/homebrew/develop/proxy.rb +``` Start proxy.py ============== @@ -216,8 +230,8 @@ an executable named `proxy` is placed under your `$PATH`. Simply type `proxy` on command line to start it with default configuration. -``` -$ proxy +```bash +❯ proxy ...[redacted]... - Loaded plugin proxy.http_proxy.HttpProxyPlugin ...[redacted]... - Starting 8 workers ...[redacted]... - Started server on ::1:8899 @@ -230,24 +244,24 @@ Things to notice from above logs: - `Loaded plugin` - `proxy.py` will load `proxy.http.proxy.HttpProxyPlugin` by default. As name suggests, this core plugin adds `http(s)` proxy server capabilities to `proxy.py` -- `Started N workers` - Use `--num-workers` flag to customize number of worker processes. +- `Started N workers` - Use `--num-workers` flag to customize number of worker processes. By default, `proxy.py` will start as many workers as there are CPU cores on the machine. -- `Started server on ::1:8899` - By default, `proxy.py` listens on IPv6 `::1`, which - is equivalent of IPv4 `127.0.0.1`. If you want to access `proxy.py` externally, - use `--hostname ::` or `--hostname 0.0.0.0` or bind to any other interface available +- `Started server on ::1:8899` - By default, `proxy.py` listens on IPv6 `::1`, which + is equivalent of IPv4 `127.0.0.1`. If you want to access `proxy.py` externally, + use `--hostname ::` or `--hostname 0.0.0.0` or bind to any other interface available on your machine. - `Port 8899` - Use `--port` flag to customize default TCP port. #### Enable DEBUG logging -All the logs above are `INFO` level logs, default `--log-level` for `proxy.py`. +All the logs above are `INFO` level logs, default `--log-level` for `proxy.py`. Lets start `proxy.py` with `DEBUG` level logging: -``` -$ proxy --log-level d +```bash +❯ proxy --log-level d ...[redacted]... - Open file descriptor soft limit set to 1024 ...[redacted]... - Loaded plugin proxy.http_proxy.HttpProxyPlugin ...[redacted]... - Started 8 workers @@ -271,35 +285,35 @@ To start `proxy.py` from source code follow these instructions: - Clone repo - ``` - $ git clone https://github.com/abhinavsingh/proxy.py.git - $ cd proxy.py + ```bash + ❯ git clone https://github.com/abhinavsingh/proxy.py.git + ❯ cd proxy.py ``` - Create a Python 3 virtual env - ``` - $ python3 -m venv venv - $ source venv/bin/activate + ```bash + ❯ python3 -m venv venv + ❯ source venv/bin/activate ``` - Install deps - ``` - $ pip install -r requirements.txt - $ pip install -r requirements-testing.txt + ```bash + ❯ pip install -r requirements.txt + ❯ pip install -r requirements-testing.txt ``` - Run tests - ``` - $ make + ```bash + ❯ make ``` - Run proxy.py - ``` - $ python -m proxy + ```bash + ❯ python -m proxy ``` Also see [Plugin Developer and Contributor Guide](#plugin-developer-and-contributor-guide) @@ -316,7 +330,7 @@ By default `docker` binary is started with IPv4 networking flags: To override input flags, start docker image as follows. For example, to check `proxy.py` version within Docker image: - $ docker run -it \ + ❯ docker run -it \ -p 8899:8899 \ --rm abhinavsingh/proxy.py:latest \ -v @@ -341,8 +355,8 @@ Add support for short links in your favorite browsers / applications. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.ShortLinkPlugin ``` @@ -370,8 +384,8 @@ Modifies POST request body before sending request to upstream server. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.ModifyPostDataPlugin ``` @@ -380,7 +394,7 @@ and enforced `Content-Type: application/json`. Verify the same using `curl -x localhost:8899 -d '{"key": "value"}' http://httpbin.org/post` -``` +```bash { "args": {}, "data": "{\"key\": \"modified\"}", @@ -424,39 +438,39 @@ without need of an actual upstream REST API server. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.ProposedRestApiPlugin ``` Verify mock API response using `curl -x localhost:8899 http://api.example.com/v1/users/` -``` +```bash {"count": 2, "next": null, "previous": null, "results": [{"email": "you@example.com", "groups": [], "url": "api.example.com/v1/users/1/", "username": "admin"}, {"email": "someone@example.com", "groups": [], "url": "api.example.com/v1/users/2/", "username": "admin"}]} ``` Verify the same by inspecting `proxy.py` logs: -``` +```bash 2019-09-27 12:44:02,212 - INFO - pid:7077 - access_log:1210 - ::1:64792 - GET None:None/v1/users/ - None None - 0 byte ``` Access log shows `None:None` as server `ip:port`. `None` simply means that the server connection was never made, since response was returned by our plugin. -Now modify `ProposedRestApiPlugin` to returns REST API mock +Now modify `ProposedRestApiPlugin` to returns REST API mock responses as expected by your clients. ### RedirectToCustomServerPlugin -Redirects all incoming `http` requests to custom web server. -By default, it redirects client requests to inbuilt web server, +Redirects all incoming `http` requests to custom web server. +By default, it redirects client requests to inbuilt web server, also running on `8899` port. Start `proxy.py` and enable inbuilt web server: -``` -$ proxy \ +```bash +❯ proxy \ --enable-web-server \ --plugins proxy.plugin.RedirectToCustomServerPlugin ``` @@ -468,13 +482,13 @@ Verify using `curl -v -x localhost:8899 http://google.com` < HTTP/1.1 404 NOT FOUND < Server: proxy.py v1.0.0 < Connection: Close -< +< * Closing connection 0 ``` -Above `404` response was returned from `proxy.py` web server. +Above `404` response was returned from `proxy.py` web server. -Verify the same by inspecting the logs for `proxy.py`. +Verify the same by inspecting the logs for `proxy.py`. Along with the proxy request log, you must also see a http web server request log. ``` @@ -484,24 +498,24 @@ Along with the proxy request log, you must also see a http web server request lo ### FilterByUpstreamHostPlugin -Drops traffic by inspecting upstream host. +Drops traffic by inspecting upstream host. By default, plugin drops traffic for `google.com` and `www.google.com`. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.FilterByUpstreamHostPlugin ``` Verify using `curl -v -x localhost:8899 http://google.com`: -``` +```bash ... [redacted] ... < HTTP/1.1 418 I'm a tea pot < Proxy-agent: proxy.py v1.0.0 * no chunk, no close, no size. Assume close to signal end -< +< * Closing connection 0 ``` @@ -509,7 +523,7 @@ Above `418 I'm a tea pot` is sent by our plugin. Verify the same by inspecting logs for `proxy.py`: -``` +```bash 2019-09-24 19:21:37,893 - ERROR - pid:50074 - handle_readables:1347 - HttpProtocolException type raised Traceback (most recent call last): ... [redacted] ... @@ -522,14 +536,14 @@ Caches Upstream Server Responses. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.CacheResponsesPlugin ``` Verify using `curl -v -x localhost:8899 http://httpbin.org/get`: -``` +```bash ... [redacted] ... < HTTP/1.1 200 OK < Access-Control-Allow-Credentials: true @@ -543,7 +557,7 @@ Verify using `curl -v -x localhost:8899 http://httpbin.org/get`: < X-XSS-Protection: 1; mode=block < Content-Length: 202 < Connection: keep-alive -< +< { "args": {}, "headers": { @@ -559,14 +573,14 @@ Verify using `curl -v -x localhost:8899 http://httpbin.org/get`: Get path to the cache file from `proxy.py` logs: -``` +```bash ... [redacted] ... - GET httpbin.org:80/get - 200 OK - 556 bytes ... [redacted] ... - Cached response at /var/folders/k9/x93q0_xn1ls9zy76m2mf2k_00000gn/T/httpbin.org-1569378301.407512.txt ``` Verify contents of the cache file `cat /path/to/your/cache/httpbin.org.txt` -``` +```bash HTTP/1.1 200 OK Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * @@ -598,18 +612,18 @@ Modifies upstream server responses. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.ManInTheMiddlePlugin ``` Verify using `curl -v -x localhost:8899 http://google.com`: -``` +```bash ... [redacted] ... < HTTP/1.1 200 OK < Content-Length: 28 -< +< * Connection #0 to host localhost left intact Hello from man in the middle ``` @@ -627,18 +641,18 @@ Let's start upstream proxies first. Start `proxy.py` on port `9000` and `9001` -``` -$ proxy --port 9000 +```bash +❯ proxy --port 9000 ``` -``` -$ proxy --port 9001 +```bash +❯ proxy --port 9001 ``` Now, start `proxy.py` with `ProxyPoolPlugin` (on default `8899` port): -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.ProxyPoolPlugin ``` @@ -657,15 +671,15 @@ Extend in-built Web Server to add Reverse Proxy capabilities. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.ReverseProxyPlugin ``` With default configuration, `ReverseProxyPlugin` plugin is equivalent to following `Nginx` config: -``` +```bash location /get { proxy_pass http://httpbin.org/get } @@ -673,7 +687,7 @@ location /get { Verify using `curl -v localhost:8899/get`: -``` +```bash { "args": {}, "headers": { @@ -692,56 +706,56 @@ Demonstrates inbuilt web server routing using plugin. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.WebServerPlugin ``` Verify using `curl -v localhost:8899/http-route-example`, should return: -``` +```bash HTTP route response ``` ## Plugin Ordering -When using multiple plugins, depending upon plugin functionality, -it might be worth considering the order in which plugins are passed +When using multiple plugins, depending upon plugin functionality, +it might be worth considering the order in which plugins are passed on the command line. -Plugins are called in the same order as they are passed. Example, -say we are using both `FilterByUpstreamHostPlugin` and -`RedirectToCustomServerPlugin`. Idea is to drop all incoming `http` -requests for `google.com` and `www.google.com` and redirect other +Plugins are called in the same order as they are passed. Example, +say we are using both `FilterByUpstreamHostPlugin` and +`RedirectToCustomServerPlugin`. Idea is to drop all incoming `http` +requests for `google.com` and `www.google.com` and redirect other `http` requests to our inbuilt web server. -Hence, in this scenario it is important to use -`FilterByUpstreamHostPlugin` before `RedirectToCustomServerPlugin`. +Hence, in this scenario it is important to use +`FilterByUpstreamHostPlugin` before `RedirectToCustomServerPlugin`. If we enable `RedirectToCustomServerPlugin` before `FilterByUpstreamHostPlugin`, -`google` requests will also get redirected to inbuilt web server, +`google` requests will also get redirected to inbuilt web server, instead of being dropped. End-to-End Encryption ===================== -By default, `proxy.py` uses `http` protocol for communication with clients e.g. `curl`, `browser`. +By default, `proxy.py` uses `http` protocol for communication with clients e.g. `curl`, `browser`. For enabling end-to-end encrypting using `tls` / `https` first generate certificates: -``` +```bash make https-certificates ``` Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --cert-file https-cert.pem \ --key-file https-key.pem ``` Verify using `curl -x https://localhost:8899 --proxy-cacert https-cert.pem https://httpbin.org/get`: -``` +```bash { "args": {}, "headers": { @@ -757,7 +771,7 @@ Verify using `curl -x https://localhost:8899 --proxy-cacert https-cert.pem https TLS Interception ================= -By default, `proxy.py` will not decrypt `https` traffic between client and server. +By default, `proxy.py` will not decrypt `https` traffic between client and server. To enable TLS interception first generate CA certificates: ``` @@ -767,8 +781,8 @@ make ca-certificates Lets also enable `CacheResponsePlugin` so that we can verify decrypted response from the server. Start `proxy.py` as: -``` -$ proxy \ +```bash +❯ proxy \ --plugins proxy.plugin.CacheResponsesPlugin \ --ca-key-file ca-key.pem \ --ca-cert-file ca-cert.pem \ @@ -777,13 +791,13 @@ $ proxy \ Verify using `curl -v -x localhost:8899 --cacert ca-cert.pem https://httpbin.org/get` -``` +```bash * issuer: C=US; ST=CA; L=SanFrancisco; O=proxy.py; OU=CA; CN=Proxy PY CA; emailAddress=proxyca@mailserver.com * SSL certificate verify ok. > GET /get HTTP/1.1 ... [redacted] ... < Connection: keep-alive -< +< { "args": {}, "headers": { @@ -801,9 +815,9 @@ The `issuer` line confirms that response was intercepted. Also verify the contents of cached response file. Get path to the cache file from `proxy.py` logs. -`$ cat /path/to/your/tmp/directory/httpbin.org-1569452863.924174.txt` +`❯ cat /path/to/your/tmp/directory/httpbin.org-1569452863.924174.txt` -``` +```bash HTTP/1.1 200 OK Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * @@ -832,7 +846,7 @@ Connection: keep-alive Viola!!! If you remove CA flags, encrypted data will be found in the cached file instead of plain text. -Now use CA flags with other +Now use CA flags with other [plugin examples](#plugin-examples) to see them work with `https` traffic. Proxy Over SSH Tunnel @@ -875,9 +889,9 @@ running on `localhost`. Start `proxy.py` as: -``` -$ # On localhost -$ proxy --enable-tunnel \ +```bash +❯ # On localhost +❯ proxy --enable-tunnel \ --tunnel-username username \ --tunnel-hostname ip.address.or.domain.name \ --tunnel-port 22 \ @@ -885,12 +899,12 @@ $ proxy --enable-tunnel \ --tunnel-remote-port 8899 ``` -Make a HTTP proxy request on `remote` server and +Make a HTTP proxy request on `remote` server and verify that response contains public IP address of `localhost` as origin: -``` -$ # On remote -$ curl -x 127.0.0.1:8899 http://httpbin.org/get +```bash +❯ # On remote +❯ curl -x 127.0.0.1:8899 http://httpbin.org/get { "args": {}, "headers": { @@ -905,7 +919,7 @@ $ curl -x 127.0.0.1:8899 http://httpbin.org/get Also, verify that `proxy.py` logs on `localhost` contains `remote` IP as client IP. -``` +```bash access_log:328 - remote:52067 - GET httpbin.org:80 ``` @@ -929,7 +943,7 @@ Embed proxy.py Start `proxy.py` in embedded mode with default configuration by using `proxy.main` method. Example: -``` +```python import proxy if __name__ == '__main__': @@ -938,7 +952,7 @@ if __name__ == '__main__': Customize startup flags by passing list of input arguments: -``` +```python import proxy if __name__ == '__main__': @@ -950,7 +964,7 @@ if __name__ == '__main__': or, customize startup flags by passing them as kwargs: -``` +```python import ipaddress import proxy @@ -971,7 +985,7 @@ Note that: Start `proxy.py` in non-blocking embedded mode with default configuration by using `start` method: Example: -``` +```python import proxy if __name__ == '__main__': @@ -999,7 +1013,7 @@ To setup and teardown `proxy.py` for your Python unittest classes, simply use `proxy.TestCase` instead of `unittest.TestCase`. Example: -``` +```python import proxy @@ -1017,7 +1031,7 @@ Note that: 3. Only a single worker is started by default (`--num-workers 1`) for faster setup and teardown. 4. Most importantly, `proxy.TestCase` also ensures `proxy.py` server is up and running before proceeding with execution of tests. By default, - `proxy.TestCase` will wait for `10 seconds` for `proxy.py` server to start, + `proxy.TestCase` will wait for `10 seconds` for `proxy.py` server to start, upon failure a `TimeoutError` exception will be raised. ## Override startup flags @@ -1025,7 +1039,7 @@ Note that: To override default startup flags, define a `PROXY_PY_STARTUP_FLAGS` variable in your test class. Example: -``` +```python class TestProxyPyEmbedded(TestCase): PROXY_PY_STARTUP_FLAGS = [ @@ -1046,7 +1060,7 @@ If for some reasons you are unable to directly use `proxy.TestCase`, then simply override `unittest.TestCase.run` yourself to setup and teardown `proxy.py`. Example: -``` +```python import unittest import proxy @@ -1063,7 +1077,7 @@ class TestProxyPyEmbedded(unittest.TestCase): super().run(result) ``` -or simply setup / teardown `proxy.py` within +or simply setup / teardown `proxy.py` within `setUpClass` and `teardownClass` class methods. Plugin Developer and Contributor Guide @@ -1074,42 +1088,42 @@ Plugin Developer and Contributor Guide As you might have guessed by now, in `proxy.py` everything is a plugin. - We enabled proxy server plugins using `--plugins` flag. - All the [plugin examples](#plugin-examples) were implementing - `HttpProxyBasePlugin`. See documentation of - [HttpProxyBasePlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L894-L938) - for available lifecycle hooks. Use `HttpProxyBasePlugin` to modify - behavior of http(s) proxy protocol between client and upstream server. + All the [plugin examples](#plugin-examples) were implementing + `HttpProxyBasePlugin`. See documentation of + [HttpProxyBasePlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L894-L938) + for available lifecycle hooks. Use `HttpProxyBasePlugin` to modify + behavior of http(s) proxy protocol between client and upstream server. Example, [FilterByUpstreamHostPlugin](#filterbyupstreamhostplugin). -- We also enabled inbuilt web server using `--enable-web-server`. - Inbuilt web server implements `HttpProtocolHandlerPlugin` plugin. - See documentation of [HttpProtocolHandlerPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L793-L850) - for available lifecycle hooks. Use `HttpProtocolHandlerPlugin` to add - new features for http(s) clients. Example, +- We also enabled inbuilt web server using `--enable-web-server`. + Inbuilt web server implements `HttpProtocolHandlerPlugin` plugin. + See documentation of [HttpProtocolHandlerPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L793-L850) + for available lifecycle hooks. Use `HttpProtocolHandlerPlugin` to add + new features for http(s) clients. Example, [HttpWebServerPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1185-L1260). - There also is a `--disable-http-proxy` flag. It disables inbuilt proxy server. Use this flag with `--enable-web-server` flag to run `proxy.py` as a programmable - http(s) server. [HttpProxyPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L941-L1182) + http(s) server. [HttpProxyPlugin](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L941-L1182) also implements `HttpProtocolHandlerPlugin`. ## Internal Architecture -- [HttpProtocolHandler](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1263-L1440) +- [HttpProtocolHandler](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L1263-L1440) thread is started with the accepted [TcpClientConnection](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L230-L237). `HttpProtocolHandler` is responsible for parsing incoming client request and invoking `HttpProtocolHandlerPlugin` lifecycle hooks. -- `HttpProxyPlugin` which implements `HttpProtocolHandlerPlugin` also has its own plugin -mechanism. Its responsibility is to establish connection between client and +- `HttpProxyPlugin` which implements `HttpProtocolHandlerPlugin` also has its own plugin +mechanism. Its responsibility is to establish connection between client and upstream [TcpServerConnection](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L204-L227) and invoke `HttpProxyBasePlugin` lifecycle hooks. -- `HttpProtocolHandler` threads are started by [Acceptor](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L424-L472) +- `HttpProtocolHandler` threads are started by [Acceptor](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L424-L472) processes. -- `--num-workers` `Acceptor` processes are started by - [AcceptorPool](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L368-L421) +- `--num-workers` `Acceptor` processes are started by + [AcceptorPool](https://github.com/abhinavsingh/proxy.py/blob/b03629fa0df1595eb4995427bc601063be7fdca9/proxy.py#L368-L421) on start-up. - `AcceptorPool` listens on server socket and pass the handler to `Acceptor` processes. @@ -1148,7 +1162,7 @@ Utilities Attempts to create an IPv4 connection, then IPv6 and finally a dual stack connection to provided address. -``` +```python >>> conn = new_socket_connection(('httpbin.org', 80)) >>> ...[ use connection ]... >>> conn.close() @@ -1161,14 +1175,14 @@ around `new_socket_connection` which ensures `conn.close` is implicit. As a context manager: -``` +```python >>> with socket_connection(('httpbin.org', 80)) as conn: >>> ... [ use connection ] ... ``` As a decorator: -``` +```python >>> @socket_connection(('httpbin.org', 80)) >>> def my_api_call(conn, *args, **kwargs): >>> ... [ use connection ] ... @@ -1180,7 +1194,7 @@ As a decorator: ##### Generate HTTP GET request -``` +```python >>> build_http_request(b'GET', b'/') b'GET / HTTP/1.1\r\n\r\n' >>> @@ -1188,8 +1202,8 @@ b'GET / HTTP/1.1\r\n\r\n' ##### Generate HTTP GET request with headers -``` ->>> build_http_request(b'GET', b'/', +```python +>>> build_http_request(b'GET', b'/', headers={b'Connection': b'close'}) b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' >>> @@ -1197,10 +1211,10 @@ b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' ##### Generate HTTP POST request with headers and body -``` +```python >>> import json ->>> build_http_request(b'POST', b'/form', - headers={b'Content-type': b'application/json'}, +>>> build_http_request(b'POST', b'/form', + headers={b'Content-type': b'application/json'}, body=proxy.bytes_(json.dumps({'email': 'hello@world.com'}))) b'POST /form HTTP/1.1\r\nContent-type: application/json\r\n\r\n{"email": "hello@world.com"}' ``` @@ -1224,8 +1238,8 @@ TODO Browse through internal class hierarchy and documentation using `pydoc3`. Example: -``` -$ pydoc3 proxy +```bash +❯ pydoc3 proxy PACKAGE CONTENTS __main__ @@ -1245,7 +1259,7 @@ Frequently Asked Questions Make sure you are using `Python 3`. Verify the version before running `proxy.py`: -`$ python --version` +`❯ python --version` ## Unable to load plugins @@ -1253,7 +1267,7 @@ Make sure plugin modules are discoverable by adding them to `PYTHONPATH`. Examp `PYTHONPATH=/path/to/my/app proxy --plugins my_app.proxyPlugin` -``` +```bash ...[redacted]... - Loaded plugin proxy.HttpProxyPlugin ...[redacted]... - Loaded plugin my_app.proxyPlugin ``` @@ -1318,8 +1332,8 @@ without any socket leaks. If nothing helps, [open an issue](https://github.com/abhinavsingh/proxy.py/issues/new) with `requests per second` sent and output of following debug script: -``` -$ ./helper/monitor_open_files.sh +```bash +❯ ./helper/monitor_open_files.sh ``` ## None:None in access logs @@ -1331,13 +1345,13 @@ that an upstream server connection was never established i.e. There can be several reasons for no upstream connection, few obvious ones include: -1. Client established a connection but never completed the request. +1. Client established a connection but never completed the request. 2. A plugin returned a response prematurely, avoiding connection to upstream server. Flags ===== -``` +```bash ❯ proxy -h usage: proxy [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] [--ca-key-file CA_KEY_FILE] [--ca-cert-dir CA_CERT_DIR] From 8babac3da2af6f1807bb9da112c4c4ddf9e4b37f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 23 Dec 2019 03:17:20 +0200 Subject: [PATCH 087/107] Update coverage from 5.0 to 5.0.1 (#241) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index ac523e608d..d9825e2841 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,5 +1,5 @@ python-coveralls==2.9.3 -coverage==5.0 +coverage==5.0.1 flake8==3.7.9 pytest==5.3.2 pytest-cov==2.8.1 From e84c212465f4fa7ffa0ca6e8f56c425f6342b2b4 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 25 Dec 2019 16:39:18 -0800 Subject: [PATCH 088/107] Integration testing (#243) * Add tests for public/private/csr generation * Add integration testing skeleton for mac and ubuntu * Merge integration within lib test to avoid too many workflows * Disable integration testing on windows for now * Use sudo to start integration test script as lsof fails on MacOS. lsof: WARNING: can't stat() vmhgfs file system * Add basic integration testing for now to assert proxy works as expected when started out of develop branch * Add a call to inbuilt http server to verify it works * wait for server to accept requests --- .github/workflows/test-library.yml | 8 +++- tests/common/test_pki.py | 39 +++++++++++++++-- tests/integration/main.sh | 67 ++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100755 tests/integration/main.sh diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index b973da3310..007b3c5ecb 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -27,11 +27,15 @@ jobs: run: | flake8 --ignore=W504 --max-line-length=127 --max-complexity=19 proxy/ tests/ setup.py mypy --strict --ignore-missing-imports proxy/ tests/ setup.py - - name: Build PyPi Package - run: python setup.py sdist - name: Run Tests run: pytest --cov=proxy tests/ - name: Upload coverage to Codecov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: codecov + - name: Integration testing + if: matrix.os != 'windows' + run: | + python setup.py install + proxy --hostname 127.0.0.1 --enable-web-server --pid-file proxy.pid --log-file proxy.log & + ./tests/integration/main.sh diff --git a/tests/common/test_pki.py b/tests/common/test_pki.py index d3796787bf..ebeb767dba 100644 --- a/tests/common/test_pki.py +++ b/tests/common/test_pki.py @@ -8,9 +8,12 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import os +import tempfile import unittest import subprocess from unittest import mock +from typing import Tuple from proxy.common import pki @@ -73,13 +76,43 @@ def test_extfile(self) -> None: b'\nsubjectAltName=DNS:proxy.py') def test_gen_private_key(self) -> None: - pass + key_path, nopass_key_path = self._gen_private_key() + self.assertTrue(os.path.exists(key_path)) + self.assertTrue(os.path.exists(nopass_key_path)) + os.remove(key_path) + os.remove(nopass_key_path) def test_gen_public_key(self) -> None: - pass + key_path, nopass_key_path, crt_path = self._gen_public_private_key() + self.assertTrue(os.path.exists(crt_path)) + # TODO: Assert generated public key matches private key + os.remove(crt_path) + os.remove(key_path) + os.remove(nopass_key_path) def test_gen_csr(self) -> None: - pass + key_path, nopass_key_path, crt_path = self._gen_public_private_key() + csr_path = os.path.join(tempfile.gettempdir(), 'test_gen_public.csr') + pki.gen_csr(csr_path, key_path, 'password', crt_path) + self.assertTrue(os.path.exists(csr_path)) + # TODO: Assert CSR is valid for provided crt and key + os.remove(csr_path) + os.remove(crt_path) + os.remove(key_path) + os.remove(nopass_key_path) def test_sign_csr(self) -> None: pass + + def _gen_public_private_key(self) -> Tuple[str, str, str]: + key_path, nopass_key_path = self._gen_private_key() + crt_path = os.path.join(tempfile.gettempdir(), 'test_gen_public.crt') + pki.gen_public_key(crt_path, key_path, 'password', '/CN=example.com') + return (key_path, nopass_key_path, crt_path) + + def _gen_private_key(self) -> Tuple[str, str]: + key_path = os.path.join(tempfile.gettempdir(), 'test_gen_private.key') + nopass_key_path = os.path.join(tempfile.gettempdir(), 'test_gen_private_nopass.key') + pki.gen_private_key(key_path, 'password') + pki.remove_passphrase(key_path, 'password', nopass_key_path) + return (key_path, nopass_key_path) diff --git a/tests/integration/main.sh b/tests/integration/main.sh new file mode 100755 index 0000000000..d75d9781de --- /dev/null +++ b/tests/integration/main.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# TODO: Option to also shutdown proxy.py after +# integration testing is done. Atleast on +# macOS and ubuntu, pkill and kill commands +# will do the job. +# +# For github action, we simply bank upon GitHub +# to clean up any background process including +# proxy.py + +# Wait for server to come up +while true; do + if [[ $(lsof -i TCP:8899 | wc -l | tr -d ' ') == 0 ]]; then + echo "Waiting for proxy..." + sleep 1 + else + break + fi +done + +# Wait for http proxy and web server to start +while true; do + curl -v \ + --max-time 1 \ + --connect-timeout 1 \ + -x localhost:8899 \ + http://localhost:8899/ 2>/dev/null + if [[ $? == 0 ]]; then + break + fi + echo "Waiting for web server to start accepting requests..." + sleep 1 +done + +# Check if proxy was started with integration +# testing web server plugin. If detected, use +# internal web server for integration testing. + +# If integration testing plugin is not found, +# detect if we have internet access. If we do, +# then use httpbin.org for integration testing. +curl -v \ + -x localhost:8899 \ + http://httpbin.org/get +if [[ $? != 0 ]]; then + echo "http request failed" + exit 1 +fi + +curl -v \ + -x localhost:8899 \ + https://httpbin.org/get +if [[ $? != 0 ]]; then + echo "https request failed" + exit 1 +fi + +curl -v \ + -x localhost:8899 \ + http://localhost:8899/ +if [[ $? != 0 ]]; then + echo "http request to built in webserver failed" + exit 1 +fi + +exit 0 From a7d4d45b3c2dac8b7f438ae2765cfd9458f2d134 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 25 Dec 2019 17:05:39 -0800 Subject: [PATCH 089/107] GitHub workflow badge (#244) * v2.x (#173) * Always update latest tag for docker releases * Update issue templates (#123) * Invoke HttpWebServerBasePlugin.handle_request for each request in HTTP/1.1 pipeline (#125) * Add tests to verify certificate generation * Separate out tests for ProtocolHandler and WebServerPlugin * Keep-alive connections for web server. TODO: Only keep-alivei if HTTP/1.1 * Add request.path to avoid build_url repeatedly whose name is also slightly misleading * Fix example usage of request.path * Pipeline only for HTTP/1.1 * Lint fix * Teardown HTTP/1.1 keep-alive request when Connection: close header is sent * Add instructions on how to build docker image locally * Move access_log to separate function for pretty logging * Reduce docker image size * Ensure teardown is always accompanied with Connection: close header Fix tests * Invoke proxy plugin handle_request for each request in HTTP/1.1 pipeline or when TLS interception is enabled (#128) * Add tests for is_http_1_1_keep_alive * Add ModifyPostDataPlugin in README * Fixes #126 * Refactor HttpProxyBasePlugin API * before_upstream_connection too can drop request by returning None * Remove HTTP Server startup during tests, no longer used * Removed unused imports * Simplify load_plugins * Add --timeout flag with default value of 10 second. (#129) * Add --timeout flag with default value of 5. This value was previously hardcoded to 30 * --timeout=10 by default * Dispatch 408 timeout when connection is dropped due to inactivity * Add httpStatusCodes named tuple * Update plugin client connection reference after TLS connection upgrade * Test plugin examples (#130) * Add tests for plugin_examples.* to ensure we never break functionality * Add tests for plugin_examples.* * Test man in the middle * Lint fixes * Checkin * Add tests for plugin examples with TLS encryption enabled * Threadless execution using coroutines (#134) * Workers need not register/unregister sock for every loop * No need of explicit socket.settimeout(0) which is same as socket.setblocking(False) * Remove settimeout assertion * Only store sender side of Pipe(). Also ensure both end of the Pipe() are closed on shutdown * Make now global. Also we seem to be using datetime.utcnow and time.time for similar purposes * Use time.time throughout. Remove incomplete test_cache_responses_plugin to avoid resource leak in tests * Remove unused * Wrap selector register/unregister within a context manager * Refactor in preparation of threadless request handling * MyPy generator fix * Add --threadless flag * Internally call them acceptors * Internally use acceptors * Add Threadless class. Also no need to pass family over pipe to acceptors. * Make threadless work for a single client :) * Threadless is soon be our default * Close client queue * Use context manager for register/unregister * Fix Acceptor tests broken after refactoring * Use asyncio tasks to invoke ProtocolHandle.handle_events This gives all client threads a chance to respond without waiting for other handlers to return. * Explicitly initialize event loop per Threadless process * Mypy fixes * Add ThreadlessWork abstract class implemented by ProtocolHandler * Add benchmark.py Avoid TIME_WAIT by properly shutting down the connection. * Add benchmark.py as part of testing workflow * When e2e encryption is enabled, unwrap socket before shutdown to ensure CLOSED state * MyPy fixes, Union should have worked, but likely unwrap is not part of socket.socket hence * Unwrap if wrapped before shutdown * Unwrap if wrapped before shutdown * socket.SHUT_RDWR will cause leaks * MyPy * Add instructions for monitor.sh * Avoid recursive exception in new_socket_connection and only invoke plugins/shutdown if server connection was initialized * Add Fast & Scalable section * Update internal classes section * Dont print out local dir path in help text :) * Refactor * Fix a bug where response parser for HTTP only requests was reused for pipelined requests resulting in a hang * Add chrome_with_proxy.sh helper script * Handle OSError during client.flush which can happen due to invalid protocol type for socket error * Remove redundant e * Add classmethods to quickly construct a parser object * Don't raise from TcpConnection abstract class. This allows both client/socket side of communication to handle exceptions as necessary. We might refactor this again later to remove redundant code :) * Disable response parsing when TLS interception is enabled. See issue #127 * remove unused imports * Within webserver parse pipelined requests only if we have a route * Add ShortLinkPlugin plugin * Add more shortlinks * Add ShortLinkPlugin to README.md * Add path forwarding too instead of leaving as excercise ;) * Add shortlink to TOC * Ensure no socket leaks * Ensure no leaks * Naming * Default number of clients 1 * Avoid shortlinking localhost * Stress more * Remove pip upgrade for windows which seems to be failing on travis (#136) * Remove pip upgrade for windows which seems to be failing on travis * Remove windows testing on Travis, pip install is failing * Add pipeline response parsing tests (#137) * Add pipeline response parsing tests * build_http_response now only adds content-length if transfer-encoding is not provided. Also return pending raw chunks from ChunkParser so that we can parse pipelined chunk responses. * os.close only for threadless (#138) * os.close only for Threadless to avoid fd leaks * Remove os.close mock which is only called for threadless * Update pytest from 5.2.1 to 5.2.2 (#142) * Update setuptools from 41.4.0 to 41.5.0 (#145) * Update typing-extensions from 3.7.4 to 3.7.4.1 (#147) * Update flake8 from 3.7.8 to 3.7.9 (#148) * Update setuptools from 41.5.0 to 41.5.1 (#149) * Update py-spy from 0.2.2 to 0.3.0 (#144) * Proxy.py Dashboard (#141) * Remove redundant variables * Initialize frontend dashboard app (written in typescript) * Add a WebsocketFrame.text method to quickly build a text frame raw packet, also close connection for static file serving, atleast Google Chrome seems to hang up instead of closing the connection * Add read_and_build_static_file_response method for reusability in plugins * teardown websocket connection when opcode CONNECTION_CLOSE is received * First draft of proxy.py dashboard * Remove uglify, obfuscator is superb enough * Correct generic V * First draft of dashboard * ProtocolConfig is now Flags * First big refactor toward no-single-file-module * Working tests * Update dashboard for refactored imports * Remove proxy.py as now we can just call python -m proxy -h * Fix setup.py for refactored code * Banner update * Lint check * Fix dashboard static serving and no UNDER_TEST constant necessary * Add support for plugin imports when specified in path/to/module.MyPlugin * Update README with instructions to run proxy.py after refactor * Move dashboard under /dashboard path * Rename to devtools.ts * remove unused * Update github workflow for new directory structure * Update test command too * Fix coverage generation * *.py is an invalid syntax on windows * No * on windows * Enable execution via github zip downloads * Github Zip downloads cannot be executed as Github puts project under a folder named after Github project, this breaks python interpreter expectation of finding a __main__.py in the root directory * Forget zip runs for now * Initialize ProxyDashboard on page load rather than within typescript i.e. on script load * Enforce eslint with standard style * Add .editorconfig to make editor compatible with various style requirements (Makefile, Typescript, Python) * Remove extra empty line * Add ability to pass headers with HttpRequestRejected exception, also remove proxy agent header for HttpRequestRejected * Add ability to pass headers with HttpRequestRejected exception, also remove proxy agent header for HttpRequestRejected * Fix tests * Move common code under common sub-module * Move flags under common module * Move acceptor under core * Move connection under core submodule * Move chunk_parser under http * Move http_parser as http/parser * Move http_methods as http/methods * Move http_proxy as http/proxy * Move web_server as http/server * Move status_codes as http/codes * move websocket as http/websocket * Move exception under http/exception, also move http/proxy exceptions under http/exceptions * move protocol_handler as http/handler * move devtools as http/devtools * Move version under common/version * Lifecycle if now core Event * autopep8 * Add core event queue * Register / unregister handler * Enable inspection support for frontend dashboard * Dont give an illusion of exception for HttpProtocolExceptions * Update readme for refactored codebase * DictQueueType everywhere * Move all websocket API related code under WebsocketApi class * Inspection enabled on tab switch. 1. Additionally now acceptors are assigned an int id. 2. Fix tests to match change in constructor. * Corresponding ends of the work queues can be closed immediately. Since work queues between AcceptorPool and Acceptor process is used only once, close corresponding ends asap instead of at shutdown. * No need of a manager for shared multiprocess Lock. This unnecessarily creates additional manager process. * Move threadless into its own module * Merge acceptor and acceptor_pool tests * Defer os.close * Change content display with tab clicks. Also ensure relay manager shutdown. * Remove --cov flags * Use right type for SyncManager * Ensure coverage again * Print help to discover flags, --cov certainly not available on Travis for some reason * Add pytest-cov to requirements-testing * Re-add windows on .travis also add changelog to readme * Use 3.7 and no pip upgrade since it fails on travis windows * Attempt to fix pip install on windows * Disable windows on travis, it fails and uses 3.8. Try reporting coverage from github actions * Move away from coveralls, use codecov * Codecov app installation either didnt work or token still needs to be passed * Remove travis CI * Use https://github.com/codecov/codecov-action for coverage uploads * Remove run codecov * Ha, codecov action only works on linux, what a mess * Add cookie.js though unable to use it with es5/es6 modules yet * Enable testing for python 3.8 also Build dashboard during testing * No python 3.8 on github actions yet * Autopep8 * Add separate workflows for library (python) and dashboard (node) app * Type jobs not job * Add checkout * Fix parsing node version * Fix dashboard build on windows * Show codecov instead of coveralls * Update mypy==0.740 (#151) * Update README.md (#152) * Update flags * Update debugging instructions and run instructions for develops * Update references to plugins directory * For readability add sections for run from command line using pip * Move internal doc under developer section * Add option to pass fully-qualified plugin path * Update setuptools from 41.5.1 to 41.6.0 (#153) * Test refactor + Docker image CI (#154) * Move tests into individual modules too * Ensure one test class per file * Fix docker image after refactoring * Add github actions workflow for building docker image * Fix image name * Setup python required for extracting proxy version * Version will also require deps * Separate packages for Dashboard (#157) * Refactor Makefile and add dashboard setup.py * Package dashboard as proxy.py-dashboard pip package * Give dashboard releases its own version * Fix lib-package reference * Add non-blocking embedded mode feature (#159) * Fixes #158 * mypy fixes * Instructions for non-blocking embed mode * Toggle running flag before shutdown * Add private / public key generation utils which comply with new requirements on Mac OS 10.15 (#160) * Add utilities to generate private key and public keys with alternate cnames * Add separate package proxy.py-plugins, fixes #156 * Generate certificates to comply with Mac requirements. * Add utility for CSR generation and signing * Fixes #161 * Add initial pki tests * Give structure to dashboard app (#163) * Separate out files for different responsibilities. 1. Add src/plugins directory. This directory holds one typescript file per plugin. Each plugin is optionally can be displayed as a tab on the UI. 2. Move WebsocketApi to ws.ts. This file contains all websocket APIs provided by dashboard.py backend. * Make dashboard pluggable * Move devtools under core too * Register tabs dynamically * Typescript fixes for abstract interfaces * Initialize plugin app body skeleton * Call activated / deactivated on tab change * Move plugin name within plugin classes and initialize plugin within proxy dashboard constructor * templatize api development plugin * eslint fixes * use globs * Remove useless constructors * Move traffic_control outside of core plugin, it maps to several plugin examples like redirectToUpstreamHost, filterByUpstreamHost plugins (#165) * Introduce sendMessage websocket api which allows for callbacks (#166) * Introduce sendMessage websocket api which allows for callbacks, deprecate lastPingId in favor of callbacks * Let InspectTrafficPlugin handle all pushed inspection events * Add proxy.main.TestCase for unit testing Python application with proxy.py (#167) * Add demonstration of how to use proxy.py within Python application unittests * mypy fixes * test_with_proxy example * Add docs for proxy.main.TestCase. Also wait for proxy.py server to come up before running the tests. * Consistent dashboard look and feel across plugins (#169) * Explicitly link version changelog in TOC * Separate out app header body builder * Ensure unsubscribe when disabling inspection. Fixes #164 * Avoid creation of new manager per dashboard instance. * Add UI header for all plugins (tabs) * Ensure app body for all plugin skeleton * Move app-header and app-body within core for consistent dashboard look and feel * Consistent UI header body for plugins * autopep8 * Dashboard Inspect traffic tab + devtools (#170) * Explicitly link version changelog in TOC * Separate out app header body builder * Ensure unsubscribe when disabling inspection. Fixes #164 * Avoid creation of new manager per dashboard instance. * Add UI header for all plugins (tabs) * Ensure app body for all plugin skeleton * Move app-header and app-body within core for consistent dashboard look and feel * Consistent UI header body for plugins * autopep8 * make devtools * convert to es6 * Add inspect_traffic plugin devtools app * trigger re-build, github UI is stuck * Dynamically load devtools within inspect traffic view * Just copy devtools into public/dashboard folder * Works but not how we wanted, devtools takes over entire body and doesnt contain itself within a div * Load devtools within iframe * Load devtools within iframe (#171) * Allow to pass flags as kwargs too in embed mode (#172) * Dynamically load devtools instead of on page load * Add support for passing flags as kwargs to main / start methods. * Fix tests for refactored code * Allow proxy.main, proxy.start, proxy.TestCase. Also update README.md to reflect the same. * Use Any for **opts * Move main as __init__ to avoid name conflicts * Fix tests * Update setup.py entry_point * Explicitly install requirements before setup.py * Explicitly mention packages of interest * ipv6 fails on ubuntu, use ipv4 * Make typing-extensions optional * Instead of putting it all under __init__.py, move main.py to proxy.py * Simply make setup.py module free * autopep8 * Devtools Protocol (#174) * Refine docs * Decouple relay from dashboard. Will be re-used by devtools protocol plugin. * Just have a single manager for all eventing * Ofcourse managers cant be shared across processes * Remove unused * Add DevtoolsProtocolPlugin * Emit REQUEST_COMPLETE core event * Emit only if --enable-events used * Add event emitter for response cycle * Fill up core events to devtools protocol expectations * Serve static content with Cache-Control header and gzip compression * Add PWA manifest.json and icons from sample PWA apps (replace later) * Catch any exception and be ssl agnostic * Add CSP headers and avoid inline scripts * Re-enable iframe and deobfuscation * Embed plugins within
    block * Make tab switching agnostic of block name * Add support for browser history on tab change * Default hash to #home * Switch to tab if hash is already set * Expand canvas to fill screen even without content * Remove inline css for embedded devtools * Make dashboard backend websocket API pluggable * doc * Move dashboard backend within proxy module, now ships via same pip package (#177) * Allow resources to load from http and ws when running w/o https * Move dashboard backend (dashboard.py) within proxy module. Now shipped with pip install proxy.py * Update ref to dashboard backend in github workflows * Add git-pre-commit hook file. Enable it by symlinking as .git/hooks/pre-commit * Also enable static server for dashboard serving * Move plugin_examples/ as proxy.plugin and update readme (#179) * Update dev guide * Move plugin_examples/ as proxy.plugin * Update proxy.plugin ref path in readme * Remove unnecessary port flag * Remove plugin_examples from github workflows * dashboard folder is a npm package not python package anymore * Plugins can now be tried using Docker image * Move benchmark module within proxy (#181) * Move benchmark within proxy module * chmod 0644 for benchmark.py which was executable till now * Turn utilities into its own section * Update pytest from 5.2.3 to 5.2.4 (#180) * Doc & Banner update to match GitHub (#182) * Update doc and banner * Update banner to match GitHub * Update older banners too * Add update_desc to .gitignore * Update banner for dashboard to match github * also update html, js, css * Update twine from 2.0.0 to 3.0.0 (#183) * Update pytest from 5.2.4 to 5.3.0 (#186) * Testing support improvements (#185) * Introduce proxy.Proxy context manager. This is similar to already existing context manager `start` but `proxy.Proxy` is a class with __enter__ and __exit__ methods. This allows usage of `proxy.Proxy` both as context manager and for manually setup and teardown of `proxy.py` during test setUpClass and teardownClass methods. * Gracefully shutdown threadless processes * Update tests and add a VCR method. See #184 * Refactor routes * Add Proxy to __all__ * Move TestCase under proxy.testing and test_embed.py under tests.embed module to avoid conflict with http module due to a http directory under proxy folder * Add a base cache plugin class which can be customized for custom cache behaviors * See #184. Add VCRPlugin which can be enabled within tests using a context manager, e.g. with self.vcr(): ... * Make cache plugin pluggable + make cache storage pluggable * Make dashboard npm module agnostic of top level directory * Symlink dashboard public folder * Dump devtools within dashboard public folder * Remove unused 3rd party js * Initialize Menubar (#188) * Initialize MacOS Menubar application * Dashboard plugin at-least needs a shutdown hook to teardown any thread/processes started by dashboard backend plugin * Add menu bar icon * Add respective test directories * Sync test banners * Move plugin tests under its own package * Enable daemon for threads, other this wont shutdown cleanly * Update twine from 3.0.0 to 3.1.0 (#190) * Update setuptools from 41.6.0 to 42.0.0 (#191) * Memory optimizations (#189) * Avoid persisting raw content in memory within parser, simply parse and throw-away. Addresses #187 * Clarity in test comments * Update setuptools from 42.0.0 to 42.0.1 (#193) * Make connection queue / recv work with memoryview to avoid copies (#192) * connection.recv now returns a memoryview * Make connection.queue also memoryview compliant * autopep8 * wrap in memoryview as necessary * Add default timeout for socket_connection and test_embed urllib * Fix tests * Skip TestProxyPyEmbedded for now, verifying GitHub actions * Add timeout for wait_for_server and skip only if GITHUB_ACTIONS env variable is set * Verify if GitHub Action fails due to wait_for_server spinning forever * Add test for wait_for_server timeout error exception * GitHub action hangs irrespective of wait_for_server timeout, disable TestEmbed for GitHub actions * Cleanup (#194) * Add basic README description for dashboard * Use spaces for all except makefile * enable tests for py 3.5 * Python 3.5 support label * Avoid clash of names * Add py3.8 support and bump node to 12.x (#195) * Add py3.8 support and bump node to 12.x * Add 10.x, 11.x, 12.x matrix for dashboard testing * Add Python 3.8 support label * Single tested with label * autopep8 (#196) * autopep8 * Update TestCase section * Update pytest from 5.3.0 to 5.3.1 (#197) * Update twine from 3.1.0 to 3.1.1 (#200) * Add reverse proxy example (#201) * Add reverse proxy example * Add separate sections for http proxy and web server plugins * Add doc * Add proxy over ssh tunnel functionality (#198) * update mypy to 0.750 (#204) * Test Core Eventing (#205) * Add core event tests * Update .gitignore with coverage * Add shortlink gif * Add event dispatcher test * Test event subscriber * Test Dashboard backend (#206) * Update shortlink gif name * Conditionally run workflows as necessary * Use pytest * It works but github workflow is not reporting any status :( * Separate out badges * Add python_requires to setup.py * Update setuptools from 42.0.1 to 42.0.2 (#207) * Add tox.ini (#208) * Homebrew formula (#209) * Add homebrew formula * Build PyPi package and Homebrew installation verification * Check develop * bdist_wheel reported as error: invalid command "bdist_wheel" * Move under stable/develop folders to keep Proxy class name same * uff * develop installs proxy not proxy.py binary * Prepend site-packages * Install typing-extensions explicitly with brew * Use find_packages * Most likely failing due to lack of find_packages in current develop branch * Fix windows setup.py build * test_static_web_server_serves seems flaky on Ubuntu python 3.8 * Add instructions to install using homebrew * Disable test_static_web_server_serves on GitHub actions, seems flaky * Packaging (#210) * Move docker installation steps above * Try brewing with virtualenv * depends on python * Update homebrew formula for stable release * Just test brewing on latest python * Add support for regex based routing. Fixes #203 (#211) * Remove public folder references (#212) * Refactor (#213) * Add DEFAULT_HTTP_PORT constant * Use DEFAULT_HTTP_PORT in tests * Refactor into exception module * Refactor into inspector module * Refactor into server module * Refactor into proxy module * Build docker of Python 3.8 (#214) * Move homebrew under helper (#215) * Handle ETIMEDOUT, EHOSTUNREACH, ECONNRESET on no internet (#216) * Catch TimeoutError and OSError (host unreachable) * Handle ETIMEDOUT, EHOSTUNREACH, ECONNRESET * Enable mccabe (#217) * No need of per day or week stats (#218) * Make HTTP handler constructor free of socket file number (#219) * Refactor into acceptor module * Add tunnel doc * Make fileno free * Autopep8 * Response parser now reaches COMPLETE even when no body is expected (#220) * Stash current changes * Refactor into connection module * Response parser state complete when no body expect * Raise NotImplementedError if invalid state reached within parser * Update tox from 3.14.1 to 3.14.2 (#221) * Update paramiko from 2.6.0 to 2.7.0 (#225) * Update paramiko from 2.7.0 to 2.7.1 (#227) * Proxy Pool Plugin (#228) * Add proxy pool example. See #226 * Add ProxyPoolPlugin to doc * Update pytest from 5.3.1 to 5.3.2 (#229) * Update coverage from 4.5.4 to 5.0 (#230) * Update mypy from 0.750 to 0.760 (#232) * Update mypy from 0.760 to 0.761 (#235) * Move manager initialization outside of top level scope. Fixes #233 (#236) * Share lock to acceptors via pool (#238) * Move manager initialization outside of top level scope. Fixes #233 * Share lock to acceptor via pool * Optionally initialize manager in main thread and use the same for EventQueue initialization (#239) * Highlight language syntax (#240) * Highlight lang syntax * zsh prompt * Update coverage from 5.0 to 5.0.1 (#241) * Integration testing (#243) * Add tests for public/private/csr generation * Add integration testing skeleton for mac and ubuntu * Merge integration within lib test to avoid too many workflows * Disable integration testing on windows for now * Use sudo to start integration test script as lsof fails on MacOS. lsof: WARNING: can't stat() vmhgfs file system * Add basic integration testing for now to assert proxy works as expected when started out of develop branch * Add a call to inbuilt http server to verify it works * wait for server to accept requests Co-authored-by: pyup.io bot * Add github workflow badges Co-authored-by: pyup.io bot --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6b7107f105..9101e2a23a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ [![Proxy.Py](https://raw.githubusercontent.com/abhinavsingh/proxy.py/develop/ProxyPy.png)](https://github.com/abhinavsingh/proxy.py) [![License](https://img.shields.io/github/license/abhinavsingh/proxy.py.svg)](https://opensource.org/licenses/BSD-3-Clause) -[![Build Status](https://travis-ci.org/abhinavsingh/proxy.py.svg?branch=develop)](https://travis-ci.org/abhinavsingh/proxy.py/) -[![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) [![PyPi Monthly](https://img.shields.io/pypi/dm/proxy.py.svg?color=green)](https://pypi.org/project/proxy.py/) [![Docker Pulls](https://img.shields.io/docker/pulls/abhinavsingh/proxy.py?color=green)](https://hub.docker.com/r/abhinavsingh/proxy.py) +[![No Dependencies](https://img.shields.io/static/v1?label=dependencies&message=none&color=green)](https://github.com/abhinavsingh/proxy.py) + +[![Proxy.py Library Build Status](https://github.com/abhinavsingh/proxy.py/workflows/Proxy.py%20Library/badge.svg)](https://github.com/abhinavsingh/proxy.py/actions) +[![Proxy.py Docker Build Status](https://github.com/abhinavsingh/proxy.py/workflows/Proxy.py%20Docker/badge.svg)](https://github.com/abhinavsingh/proxy.py/actions) +[![Proxy.py Docker Build Status](https://github.com/abhinavsingh/proxy.py/workflows/Proxy.py%20Dashboard/badge.svg)](https://github.com/abhinavsingh/proxy.py/actions) +[![Proxy.py Docker Build Status](https://github.com/abhinavsingh/proxy.py/workflows/Proxy.py%20Brew/badge.svg)](https://github.com/abhinavsingh/proxy.py/actions) [![Coverage](https://codecov.io/gh/abhinavsingh/proxy.py/branch/develop/graph/badge.svg)](https://codecov.io/gh/abhinavsingh/proxy.py) [![Tested With MacOS, Ubuntu, Windows, Android, Android Emulator, iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=mac%20OS%20%F0%9F%92%BB%20%7C%20Ubuntu%20%F0%9F%96%A5%20%7C%20Windows%20%F0%9F%92%BB&color=brightgreen)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) From 01652f9c199eb8ee0549f71e998ea8db4cfec065 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 25 Dec 2019 17:37:14 -0800 Subject: [PATCH 090/107] Update brew version to 2.0.0 (#245) --- helper/homebrew/stable/proxy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helper/homebrew/stable/proxy.rb b/helper/homebrew/stable/proxy.rb index 2abed63de5..c13d221bac 100644 --- a/helper/homebrew/stable/proxy.rb +++ b/helper/homebrew/stable/proxy.rb @@ -5,7 +5,7 @@ class Proxy < Formula Network monitoring, controls & Application development, testing, debugging." homepage "https://github.com/abhinavsingh/proxy.py" url "https://github.com/abhinavsingh/proxy.py/archive/master.zip" - version "1.1.1" + version "2.0.0" depends_on "python" From 22c537f83f3367ff746b71575a00d27087a1beb0 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 26 Dec 2019 08:23:16 -0800 Subject: [PATCH 091/107] Create CODE_OF_CONDUCT.md (#246) --- CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..84e4f570b5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at mailsforabhinav@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq From 67ab1733d0dc15c5e0d319addedf901c8191d017 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 29 Dec 2019 05:59:02 +0200 Subject: [PATCH 092/107] Update tox from 3.14.2 to 3.14.3 (#248) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index d9825e2841..0430abec61 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -7,5 +7,5 @@ autopep8==1.4.4 mypy==0.761 py-spy==0.3.0 codecov==2.0.15 -tox==3.14.2 +tox==3.14.3 mccabe==0.6.1 From 0ac6ee95fd3e9907cec5f19146c61e48eaa2f7c2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 2 Jan 2020 03:14:23 +0200 Subject: [PATCH 093/107] Update setuptools from 42.0.2 to 43.0.0 (#249) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index f7e543e1d1..b8307472b8 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.1 wheel==0.33.6 -setuptools==42.0.2 +setuptools==43.0.0 From 90af892ecd14c665b4c6903f6a2e0acccf52634f Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 3 Jan 2020 00:08:44 +0200 Subject: [PATCH 094/107] Update setuptools from 43.0.0 to 44.0.0 (#250) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index b8307472b8..eefbb1497a 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.1 wheel==0.33.6 -setuptools==43.0.0 +setuptools==44.0.0 From 067664e6ed3d80cfe13c8aece1eb18059a5db205 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 7 Jan 2020 04:21:11 +0200 Subject: [PATCH 095/107] Update coverage from 5.0.1 to 5.0.2 (#252) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 0430abec61..32925741c0 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,5 +1,5 @@ python-coveralls==2.9.3 -coverage==5.0.1 +coverage==5.0.2 flake8==3.7.9 pytest==5.3.2 pytest-cov==2.8.1 From ac29e34137203b7d9e1ca29870d329e6f5b0b945 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 6 Jan 2020 19:51:18 -0800 Subject: [PATCH 096/107] Add CLI usage for pki.py and update Makefile (#254) * Add CLI usage for pki.py * Bump to 2.1.0 * Replace direct openssl invocation with pki utility * Bolder * Ordering and version in README * Refine help --- .gitignore | 1 + Makefile | 23 +++++++++++--- README.md | 66 +++++++++++++++++++++++++++++---------- proxy/common/pki.py | 68 +++++++++++++++++++++++++++++++++++++++++ proxy/common/version.py | 2 +- 5 files changed, 138 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 45423f12d5..343de585d2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ proxy.py.iml *.csr *.crt *.key +*.pem venv* cover diff --git a/Makefile b/Makefile index 0dfd26ed85..d47a5543c4 100644 --- a/Makefile +++ b/Makefile @@ -30,18 +30,31 @@ autopep8: https-certificates: # Generate server key - openssl genrsa -out $(HTTPS_KEY_FILE_PATH) 2048 + python -m proxy.common.pki gen_private_key \ + --private-key-path $(HTTPS_KEY_FILE_PATH) + python -m proxy.common.pki remove_passphrase \ + --private-key-path $(HTTPS_KEY_FILE_PATH) # Generate server certificate - openssl req -new -x509 -days 3650 -key $(HTTPS_KEY_FILE_PATH) -out $(HTTPS_CERT_FILE_PATH) + python -m proxy.common.pki gen_public_key \ + --private-key-path $(HTTPS_KEY_FILE_PATH) \ + --public-key-path $(HTTPS_CERT_FILE_PATH) ca-certificates: # Generate CA key - openssl genrsa -out $(CA_KEY_FILE_PATH) 2048 + python -m proxy.common.pki gen_private_key \ + --private-key-path $(CA_KEY_FILE_PATH) + python -m proxy.common.pki remove_passphrase \ + --private-key-path $(CA_KEY_FILE_PATH) # Generate CA certificate - openssl req -new -x509 -days 3650 -key $(CA_KEY_FILE_PATH) -out $(CA_CERT_FILE_PATH) + python -m proxy.common.pki gen_public_key \ + --private-key-path $(CA_KEY_FILE_PATH) \ + --public-key-path $(CA_CERT_FILE_PATH) # Generate key that will be used to generate domain certificates on the fly # Generated certificates are then signed with CA certificate / key generated above - openssl genrsa -out $(CA_SIGNING_KEY_FILE_PATH) 2048 + python -m proxy.common.pki gen_private_key \ + --private-key-path $(CA_SIGNING_KEY_FILE_PATH) + python -m proxy.common.pki remove_passphrase \ + --private-key-path $(CA_SIGNING_KEY_FILE_PATH) lib-clean: find . -name '*.pyc' -exec rm -f {} + diff --git a/README.md b/README.md index 9101e2a23a..08841ae868 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,9 @@ Table of Contents * [Http](#http-client) * [build_http_request](#build_http_request) * [build_http_response](#build_http_response) - * [Websocket](#websocket) - * [WebsocketFrame](#websocketframe) - * [WebsocketClient](#websocketclient) + * [Public Key Infrastructure](#pki) + * [API Usage](#api-usage) + * [CLI Usage](#cli-usage) * [Frequently Asked Questions](#frequently-asked-questions) * [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax) * [Unable to load plugins](#unable-to-load-plugins) @@ -1161,7 +1161,7 @@ Utilities ## TCP Sockets -#### new_socket_connection +### new_socket_connection Attempts to create an IPv4 connection, then IPv6 and finally a dual stack connection to provided address. @@ -1172,7 +1172,7 @@ finally a dual stack connection to provided address. >>> conn.close() ``` -#### socket_connection +### socket_connection `socket_connection` is a convenient decorator + context manager around `new_socket_connection` which ensures `conn.close` is implicit. @@ -1194,9 +1194,9 @@ As a decorator: ## Http Client -#### build_http_request +### build_http_request -##### Generate HTTP GET request +#### Generate HTTP GET request ```python >>> build_http_request(b'GET', b'/') @@ -1204,7 +1204,7 @@ b'GET / HTTP/1.1\r\n\r\n' >>> ``` -##### Generate HTTP GET request with headers +#### Generate HTTP GET request with headers ```python >>> build_http_request(b'GET', b'/', @@ -1213,7 +1213,7 @@ b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' >>> ``` -##### Generate HTTP POST request with headers and body +#### Generate HTTP POST request with headers and body ```python >>> import json @@ -1223,19 +1223,53 @@ b'GET / HTTP/1.1\r\nConnection: close\r\n\r\n' b'POST /form HTTP/1.1\r\nContent-type: application/json\r\n\r\n{"email": "hello@world.com"}' ``` -#### build_http_response +### build_http_response TODO -## Websocket +## PKI -#### WebsocketFrame +### API Usage -TODO +#### gen_private_key +#### gen_public_key +#### remove_passphrase +#### gen_csr +#### sign_csr -#### WebsocketClient +See [pki.py](https://github.com/abhinavsingh/proxy.py/blob/develop/proxy/common/pki.py) are +method definitions. -TODO +### CLI Usage + +Use `proxy.common.pki` module for: + +1) Generation of public and private keys +2) Generating CSR requests +3) Signing CSR requests using custom CA. + +```bash +python -m proxy.common.pki -h +usage: pki.py [-h] [--password PASSWORD] [--private-key-path PRIVATE_KEY_PATH] + [--public-key-path PUBLIC_KEY_PATH] [--subject SUBJECT] + action + +proxy.py v2.1.0 : PKI Utility + +positional arguments: + action Valid actions: remove_passphrase, gen_private_key, + gen_public_key, gen_csr, sign_csr + +optional arguments: + -h, --help show this help message and exit + --password PASSWORD Password to use for encryption. Default: proxy.py + --private-key-path PRIVATE_KEY_PATH + Private key path + --public-key-path PUBLIC_KEY_PATH + Public key path + --subject SUBJECT Subject to use for public key generation. Default: + /CN=example.com +``` ## Internal Documentation @@ -1377,7 +1411,7 @@ usage: proxy [-h] [--backlog BACKLOG] [--basic-auth BASIC_AUTH] [--static-server-dir STATIC_SERVER_DIR] [--threadless] [--timeout TIMEOUT] [--version] -proxy.py v2.0.0 +proxy.py v2.1.0 optional arguments: -h, --help show this help message and exit diff --git a/proxy/common/pki.py b/proxy/common/pki.py index 024e44770c..0f1030b616 100644 --- a/proxy/common/pki.py +++ b/proxy/common/pki.py @@ -8,6 +8,8 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import sys +import argparse import contextlib import os import uuid @@ -18,6 +20,7 @@ from .utils import bytes_ from .constants import COMMA +from .version import __version__ logger = logging.getLogger(__name__) @@ -212,3 +215,68 @@ def run_openssl_command(command: List[str], timeout: int) -> bool: ) cmd.communicate(timeout=timeout) return cmd.returncode == 0 + + +if __name__ == '__main__': + available_actions = ( + 'remove_passphrase', 'gen_private_key', 'gen_public_key', + 'gen_csr', 'sign_csr' + ) + + parser = argparse.ArgumentParser( + description='proxy.py v%s : PKI Utility' % __version__, + ) + parser.add_argument( + 'action', + type=str, + default=None, + help='Valid actions: ' + ', '.join(available_actions) + ) + parser.add_argument( + '--password', + type=str, + default='proxy.py', + help='Password to use for encryption. Default: proxy.py', + ) + parser.add_argument( + '--private-key-path', + type=str, + default=None, + help='Private key path', + ) + parser.add_argument( + '--public-key-path', + type=str, + default=None, + help='Public key path', + ) + parser.add_argument( + '--subject', + type=str, + default='/CN=example.com', + help='Subject to use for public key generation. Default: /CN=example.com', + ) + args = parser.parse_args(sys.argv[1:]) + + # Validation + if args.action not in available_actions: + print('Invalid --action. Valid values ' + ', '.join(available_actions)) + sys.exit(1) + if args.action in ('gen_private_key', 'gen_public_key'): + if args.private_key_path is None: + print('--private-key-path is required for ' + args.action) + sys.exit(1) + if args.action == 'gen_public_key': + if args.public_key_path is None: + print('--public-key-file is required for private key generation') + sys.exit(1) + + # Execute + if args.action == 'gen_private_key': + gen_private_key(args.private_key_path, args.password) + elif args.action == 'gen_public_key': + gen_public_key(args.public_key_path, args.private_key_path, + args.password, args.subject) + elif args.action == 'remove_passphrase': + remove_passphrase(args.private_key_path, args.password, + args.private_key_path) diff --git a/proxy/common/version.py b/proxy/common/version.py index 36d645cd90..1a23f7912d 100644 --- a/proxy/common/version.py +++ b/proxy/common/version.py @@ -8,5 +8,5 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -VERSION = (2, 0, 0) +VERSION = (2, 1, 0) __version__ = '.'.join(map(str, VERSION[0:3])) From ee4e4ce41efe042d4dbac108f0120e81b640ef49 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 6 Jan 2020 21:23:38 -0800 Subject: [PATCH 097/107] Add py.typed marker, add version checker, remove deprecated methods (#255) * Add py.typed marker * Add version-check.py * Remove deprecated assertDictContainsSubset usage --- Makefile | 12 ++- README.md | 13 +++ proxy/py.typed | 1 + setup.py | 159 +++++++++++++++++---------------- tests/http/test_http_parser.py | 29 +++--- version-check.py | 22 +++++ 6 files changed, 138 insertions(+), 98 deletions(-) create mode 100644 proxy/py.typed create mode 100644 version-check.py diff --git a/Makefile b/Makefile index d47a5543c4..17152b4e8f 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,12 @@ CA_CERT_FILE_PATH := ca-cert.pem CA_SIGNING_KEY_FILE_PATH := ca-signing-key.pem .PHONY: all https-certificates ca-certificates autopep8 devtools -.PHONY: lib-clean lib-test lib-package lib-release-test lib-release lib-coverage lib-lint lib-profile +.PHONY: lib-version lib-clean lib-test lib-package lib-coverage lib-lint +.PHONY: lib-release-test lib-release lib-profile .PHONY: container container-run container-release .PHONY: dashboard dashboard-clean -all: lib-clean lib-test +all: lib-test devtools: pushd dashboard && npm run devtools && popd @@ -56,6 +57,9 @@ ca-certificates: python -m proxy.common.pki remove_passphrase \ --private-key-path $(CA_SIGNING_KEY_FILE_PATH) +lib-version: + python version-check.py + lib-clean: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + @@ -72,10 +76,10 @@ lib-lint: flake8 --ignore=W504 --max-line-length=127 --max-complexity=19 proxy/ tests/ setup.py mypy --strict --ignore-missing-imports proxy/ tests/ setup.py -lib-test: lib-lint +lib-test: lib-clean lib-version lib-lint pytest -v tests/ -lib-package: lib-clean +lib-package: lib-clean lib-version python setup.py sdist lib-release-test: lib-package diff --git a/README.md b/README.md index 08841ae868..83fceed35d 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Table of Contents * [API Usage](#api-usage) * [CLI Usage](#cli-usage) * [Frequently Asked Questions](#frequently-asked-questions) + * [Threads vs Threadless](#threads-vs-threadless) * [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax) * [Unable to load plugins](#unable-to-load-plugins) * [Unable to connect with proxy.py from remote host](#unable-to-connect-with-proxypy-from-remote-host) @@ -1293,6 +1294,18 @@ FILE Frequently Asked Questions ========================== +## Threads vs Threadless + +Pre v2.x, `proxy.py` used to spawn new threads for handling +client requests. + +Starting v2.x, `proxy.py` added support for threadless execution of +client requests using `asyncio`. + +In future, threadless execution will be the default mode. +Till then if you are interested in trying it out, +start `proxy.py` with `--threadless` flag. + ## SyntaxError: invalid syntax Make sure you are using `Python 3`. Verify the version before running `proxy.py`: diff --git a/proxy/py.typed b/proxy/py.typed new file mode 100644 index 0000000000..d5b3fa90fa --- /dev/null +++ b/proxy/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. The proxy package uses inline types. diff --git a/setup.py b/setup.py index 5e7b9d98e0..8ff5ec1f2f 100644 --- a/setup.py +++ b/setup.py @@ -10,89 +10,92 @@ """ from setuptools import setup, find_packages -VERSION = (2, 0, 0) +VERSION = (2, 1, 0) __version__ = '.'.join(map(str, VERSION[0:3])) -__description__ = '⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file.' +__description__ = '''⚡⚡⚡Fast, Lightweight, Pluggable, TLS interception capable proxy server + focused on Network monitoring, controls & Application development, testing, debugging.''' __author__ = 'Abhinav Singh' __author_email__ = 'mailsforabhinav@gmail.com' __homepage__ = 'https://github.com/abhinavsingh/proxy.py' __download_url__ = '%s/archive/master.zip' % __homepage__ __license__ = 'BSD' -setup( - name='proxy.py', - version=__version__, - author=__author__, - author_email=__author_email__, - url=__homepage__, - description=__description__, - long_description=open('README.md', 'r', encoding='utf-8').read().strip(), - long_description_content_type='text/markdown', - download_url=__download_url__, - license=__license__, - python_requires='!=2.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', - zip_safe=True, - packages=find_packages(exclude=["tests", "tests.*"]), - install_requires=open('requirements.txt', 'r').read().strip().split(), - entry_points={ - 'console_scripts': [ - 'proxy = proxy:entry_point' - ] - }, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Environment :: No Input/Output (Daemon)', - 'Environment :: Web Environment', - 'Environment :: MacOS X', - 'Environment :: Plugins', - 'Environment :: Win32 (MS Windows)', - 'Framework :: Robot Framework', - 'Framework :: Robot Framework :: Library', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Operating System :: MacOS', - 'Operating System :: MacOS :: MacOS 9', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Operating System :: Unix', - 'Operating System :: Microsoft', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows 10', - 'Operating System :: Android', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: Implementation', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Internet', - 'Topic :: Internet :: Proxy Servers', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Browsers', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', - 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Topic :: Software Development :: Debuggers', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: System :: Monitoring', - 'Topic :: System :: Networking', - 'Topic :: System :: Networking :: Firewalls', - 'Topic :: System :: Networking :: Monitoring', - 'Topic :: Utilities', - 'Typing :: Typed', - ], - keywords=( - 'http, proxy, http proxy server, proxy server, http server,' - 'http web server, proxy framework, web framework, Python3' +if __name__ == '__main__': + setup( + name='proxy.py', + version=__version__, + author=__author__, + author_email=__author_email__, + url=__homepage__, + description=__description__, + long_description=open('README.md', 'r', encoding='utf-8').read().strip(), + long_description_content_type='text/markdown', + download_url=__download_url__, + license=__license__, + python_requires='!=2.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', + zip_safe=False, + packages=find_packages(exclude=['tests', 'tests.*']), + package_data={'proxy': ['py.typed']}, + install_requires=open('requirements.txt', 'r').read().strip().split(), + entry_points={ + 'console_scripts': [ + 'proxy = proxy:entry_point' + ] + }, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Environment :: No Input/Output (Daemon)', + 'Environment :: Web Environment', + 'Environment :: MacOS X', + 'Environment :: Plugins', + 'Environment :: Win32 (MS Windows)', + 'Framework :: Robot Framework', + 'Framework :: Robot Framework :: Library', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: MacOS', + 'Operating System :: MacOS :: MacOS 9', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Unix', + 'Operating System :: Microsoft', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft :: Windows :: Windows 10', + 'Operating System :: Android', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: Implementation', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Topic :: Internet', + 'Topic :: Internet :: Proxy Servers', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Browsers', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries', + 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'Topic :: Software Development :: Debuggers', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Networking', + 'Topic :: System :: Networking :: Firewalls', + 'Topic :: System :: Networking :: Monitoring', + 'Topic :: Utilities', + 'Typing :: Typed', + ], + keywords=( + 'http, proxy, http proxy server, proxy server, http server,' + 'http web server, proxy framework, web framework, Python3' + ) ) -) diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index ee13621125..6867749c3b 100644 --- a/tests/http/test_http_parser.py +++ b/tests/http/test_http_parser.py @@ -134,8 +134,7 @@ def test_get_full_parse(self) -> None: self.assertEqual(self.parser.url.port, None) self.assertEqual(self.parser.version, b'HTTP/1.1') self.assertEqual(self.parser.state, httpParserStates.COMPLETE) - self.assertDictContainsSubset( - {b'host': (b'Host', b'example.com')}, self.parser.headers) + self.assertEqual(self.parser.headers[b'host'], (b'Host', b'example.com')) self.parser.del_headers([b'host']) self.parser.add_headers([(b'Host', b'example.com')]) self.assertEqual( @@ -189,8 +188,7 @@ def test_get_partial_parse1(self) -> None: self.parser.parse(CRLF * 2) self.assertEqual(self.parser.total_size, len(pkt) + (3 * len(CRLF)) + len(host_hdr)) - self.assertDictContainsSubset( - {b'host': (b'Host', b'localhost:8080')}, self.parser.headers) + self.assertEqual(self.parser.headers[b'host'], (b'Host', b'localhost:8080')) self.assertEqual(self.parser.state, httpParserStates.COMPLETE) def test_get_partial_parse2(self) -> None: @@ -207,8 +205,7 @@ def test_get_partial_parse2(self) -> None: self.assertEqual(self.parser.state, httpParserStates.LINE_RCVD) self.parser.parse(b'localhost:8080' + CRLF) - self.assertDictContainsSubset( - {b'host': (b'Host', b'localhost:8080')}, self.parser.headers) + self.assertEqual(self.parser.headers[b'host'], (b'Host', b'localhost:8080')) self.assertEqual(self.parser.buffer, b'') self.assertEqual( self.parser.state, @@ -216,8 +213,8 @@ def test_get_partial_parse2(self) -> None: self.parser.parse(b'Content-Type: text/plain' + CRLF) self.assertEqual(self.parser.buffer, b'') - self.assertDictContainsSubset( - {b'content-type': (b'Content-Type', b'text/plain')}, self.parser.headers) + self.assertEqual( + self.parser.headers[b'content-type'], (b'Content-Type', b'text/plain')) self.assertEqual( self.parser.state, httpParserStates.RCVING_HEADERS) @@ -239,10 +236,10 @@ def test_post_full_parse(self) -> None: self.assertEqual(self.parser.url.hostname, b'localhost') self.assertEqual(self.parser.url.port, None) self.assertEqual(self.parser.version, b'HTTP/1.1') - self.assertDictContainsSubset( - {b'content-type': (b'Content-Type', b'application/x-www-form-urlencoded')}, self.parser.headers) - self.assertDictContainsSubset( - {b'content-length': (b'Content-Length', b'7')}, self.parser.headers) + self.assertEqual(self.parser.headers[b'content-type'], + (b'Content-Type', b'application/x-www-form-urlencoded')) + self.assertEqual(self.parser.headers[b'content-length'], + (b'Content-Length', b'7')) self.assertEqual(self.parser.body, b'a=b&c=d') self.assertEqual(self.parser.buffer, b'') self.assertEqual(self.parser.state, httpParserStates.COMPLETE) @@ -376,8 +373,8 @@ def test_response_parse(self) -> None: b'\n' + b'301 Moved\n

    301 Moved

    \nThe document has moved\n' + b'here.\r\n\r\n') - self.assertDictContainsSubset( - {b'content-length': (b'Content-Length', b'219')}, self.parser.headers) + self.assertEqual(self.parser.headers[b'content-length'], + (b'Content-Length', b'219')) self.assertEqual(self.parser.state, httpParserStates.COMPLETE) def test_response_partial_parse(self) -> None: @@ -394,8 +391,8 @@ def test_response_partial_parse(self) -> None: b'X-XSS-Protection: 1; mode=block\r\n', b'X-Frame-Options: SAMEORIGIN\r\n' ])) - self.assertDictContainsSubset( - {b'x-frame-options': (b'X-Frame-Options', b'SAMEORIGIN')}, self.parser.headers) + self.assertEqual(self.parser.headers[b'x-frame-options'], + (b'X-Frame-Options', b'SAMEORIGIN')) self.assertEqual( self.parser.state, httpParserStates.RCVING_HEADERS) diff --git a/version-check.py b/version-check.py new file mode 100644 index 0000000000..6732a6e964 --- /dev/null +++ b/version-check.py @@ -0,0 +1,22 @@ +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable + proxy server for Application debugging, testing and development. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import sys +from proxy.common.version import __version__ as lib_version +from setup import __version__ as pkg_version + +# This script ensures our versions never run out of sync. +# +# 1. setup.py doesn't import proxy and hence they both use +# their own respective __version__ +# 2. TODO: Version is also hardcoded in homebrew stable package +# installer file, but it only needs to match with other +# versions if git branch is master +if lib_version != pkg_version: + sys.exit(1) From b2a16046c2ce4377463470b99e2ab48ffd7492b4 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 7 Jan 2020 10:35:25 -0800 Subject: [PATCH 098/107] Invoke github actions for all pull requests (#259) --- .github/workflows/test-brew.yml | 2 +- .github/workflows/test-dashboard.yml | 2 +- .github/workflows/test-docker.yml | 2 +- .github/workflows/test-library.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-brew.yml b/.github/workflows/test-brew.yml index 10d217aafb..ff9b992cc4 100644 --- a/.github/workflows/test-brew.yml +++ b/.github/workflows/test-brew.yml @@ -1,6 +1,6 @@ name: Proxy.py Brew -on: [push] +on: [push, pull_request] jobs: build: diff --git a/.github/workflows/test-dashboard.yml b/.github/workflows/test-dashboard.yml index c3711e7401..ae1bb107e1 100644 --- a/.github/workflows/test-dashboard.yml +++ b/.github/workflows/test-dashboard.yml @@ -1,6 +1,6 @@ name: Proxy.py Dashboard -on: [push] +on: [push, pull_request] jobs: build: diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index ddb7f60910..7e1d433572 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -1,6 +1,6 @@ name: Proxy.py Docker -on: [push] +on: [push, pull_request] jobs: build: diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 007b3c5ecb..65059ecae6 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -1,6 +1,6 @@ name: Proxy.py Library -on: [push] +on: [push, pull_request] jobs: build: From 6740b2857a031b69ea9f65c9614962c783a67c52 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 7 Jan 2020 11:38:25 -0800 Subject: [PATCH 099/107] Refactor into event submodule (#257) --- README.md | 6 +- proxy/core/event.py | 228 --------------------------------- proxy/core/event/__init__.py | 22 ++++ proxy/core/event/dispatcher.py | 91 +++++++++++++ proxy/core/event/names.py | 23 ++++ proxy/core/event/queue.py | 78 +++++++++++ proxy/core/event/subscriber.py | 86 +++++++++++++ version-check.py | 8 +- 8 files changed, 309 insertions(+), 233 deletions(-) delete mode 100644 proxy/core/event.py create mode 100644 proxy/core/event/__init__.py create mode 100644 proxy/core/event/dispatcher.py create mode 100644 proxy/core/event/names.py create mode 100644 proxy/core/event/queue.py create mode 100644 proxy/core/event/subscriber.py diff --git a/README.md b/README.md index 83fceed35d..d093a77671 100644 --- a/README.md +++ b/README.md @@ -1238,8 +1238,9 @@ TODO #### gen_csr #### sign_csr -See [pki.py](https://github.com/abhinavsingh/proxy.py/blob/develop/proxy/common/pki.py) are -method definitions. +See [pki.py](https://github.com/abhinavsingh/proxy.py/blob/develop/proxy/common/pki.py) for +method parameters and [test_pki.py](https://github.com/abhinavsingh/proxy.py/blob/develop/tests/common/test_pki.py) +for usage examples. ### CLI Usage @@ -1303,6 +1304,7 @@ Starting v2.x, `proxy.py` added support for threadless execution of client requests using `asyncio`. In future, threadless execution will be the default mode. + Till then if you are interested in trying it out, start `proxy.py` with `--threadless` flag. diff --git a/proxy/core/event.py b/proxy/core/event.py deleted file mode 100644 index 00fec14d96..0000000000 --- a/proxy/core/event.py +++ /dev/null @@ -1,228 +0,0 @@ -# -*- coding: utf-8 -*- -""" - proxy.py - ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on - Network monitoring, controls & Application development, testing, debugging. - - :copyright: (c) 2013-present by Abhinav Singh and contributors. - :license: BSD, see LICENSE for more details. -""" -import os -import queue -import time -import threading -import multiprocessing -import logging -import uuid - -from typing import Dict, Optional, Any, NamedTuple, List, Callable - -from ..common.types import DictQueueType - -logger = logging.getLogger(__name__) - - -EventNames = NamedTuple('EventNames', [ - ('SUBSCRIBE', int), - ('UNSUBSCRIBE', int), - ('WORK_STARTED', int), - ('WORK_FINISHED', int), - ('REQUEST_COMPLETE', int), - ('RESPONSE_HEADERS_COMPLETE', int), - ('RESPONSE_CHUNK_RECEIVED', int), - ('RESPONSE_COMPLETE', int), -]) -eventNames = EventNames(1, 2, 3, 4, 5, 6, 7, 8) - - -class EventQueue: - """Global event queue. - - Each event contains: - - 1. Request ID - Globally unique - 2. Process ID - Process ID of event publisher. - This will be process id of acceptor workers. - 3. Thread ID - Thread ID of event publisher. - When --threadless is enabled, this value will - be same for all the requests - received by a single acceptor worker. - When --threadless is disabled, this value will be - Thread ID of the thread handling the client request. - 4. Event Timestamp - Time when this event occur - 5. Event Name - One of the defined or custom event name - 6. Event Payload - Optional data associated with the event - 7. Publisher ID (optional) - Optionally, publishing entity unique name / ID - """ - - def __init__(self, queue: DictQueueType) -> None: - self.queue = queue - - def publish( - self, - request_id: str, - event_name: int, - event_payload: Dict[str, Any], - publisher_id: Optional[str] = None - ) -> None: - self.queue.put({ - 'request_id': request_id, - 'process_id': os.getpid(), - 'thread_id': threading.get_ident(), - 'event_timestamp': time.time(), - 'event_name': event_name, - 'event_payload': event_payload, - 'publisher_id': publisher_id, - }) - - def subscribe( - self, - sub_id: str, - channel: DictQueueType) -> None: - """Subscribe to global events.""" - self.queue.put({ - 'event_name': eventNames.SUBSCRIBE, - 'event_payload': {'sub_id': sub_id, 'channel': channel}, - }) - - def unsubscribe( - self, - sub_id: str) -> None: - """Unsubscribe by subscriber id.""" - self.queue.put({ - 'event_name': eventNames.UNSUBSCRIBE, - 'event_payload': {'sub_id': sub_id}, - }) - - -class EventDispatcher: - """Core EventDispatcher. - - Provides: - 1. A dispatcher module which consumes core events and dispatches - them to EventQueueBasePlugin - 2. A publish utility for publishing core events into - global events queue. - - Direct consuming from global events queue outside of dispatcher - module is not-recommended. Python native multiprocessing queue - doesn't provide a fanout functionality which core dispatcher module - implements so that several plugins can consume same published - event at a time. - - When --enable-events is used, a multiprocessing.Queue is created and - attached to global Flags. This queue can then be used for - dispatching an Event dict object into the queue. - - When --enable-events is used, dispatcher module is automatically - started. Dispatcher module also ensures that queue is not full and - doesn't utilize too much memory in case there are no event plugins - enabled. - """ - - def __init__( - self, - shutdown: threading.Event, - event_queue: EventQueue) -> None: - self.shutdown: threading.Event = shutdown - self.event_queue: EventQueue = event_queue - self.subscribers: Dict[str, DictQueueType] = {} - - def handle_event(self, ev: Dict[str, Any]) -> None: - if ev['event_name'] == eventNames.SUBSCRIBE: - self.subscribers[ev['event_payload']['sub_id']] = \ - ev['event_payload']['channel'] - elif ev['event_name'] == eventNames.UNSUBSCRIBE: - del self.subscribers[ev['event_payload']['sub_id']] - else: - # logger.info(ev) - unsub_ids: List[str] = [] - for sub_id in self.subscribers: - try: - self.subscribers[sub_id].put(ev) - except BrokenPipeError: - unsub_ids.append(sub_id) - for sub_id in unsub_ids: - del self.subscribers[sub_id] - - def run_once(self) -> None: - ev: Dict[str, Any] = self.event_queue.queue.get(timeout=1) - self.handle_event(ev) - - def run(self) -> None: - try: - while not self.shutdown.is_set(): - try: - self.run_once() - except queue.Empty: - pass - except EOFError: - pass - except KeyboardInterrupt: - pass - except Exception as e: - logger.exception('Event dispatcher exception', exc_info=e) - - -class EventSubscriber: - """Core event subscriber.""" - - def __init__(self, event_queue: EventQueue) -> None: - self.manager = multiprocessing.Manager() - self.event_queue = event_queue - self.relay_thread: Optional[threading.Thread] = None - self.relay_shutdown: Optional[threading.Event] = None - self.relay_channel: Optional[DictQueueType] = None - self.relay_sub_id: Optional[str] = None - - def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: - self.relay_shutdown = threading.Event() - self.relay_channel = self.manager.Queue() - self.relay_thread = threading.Thread( - target=self.relay, - args=(self.relay_shutdown, self.relay_channel, callback)) - self.relay_thread.start() - self.relay_sub_id = uuid.uuid4().hex - self.event_queue.subscribe(self.relay_sub_id, self.relay_channel) - logger.debug( - 'Subscribed relay sub id %s from core events', - self.relay_sub_id) - - def unsubscribe(self) -> None: - if self.relay_sub_id is None: - logger.warning('Unsubscribe called without existing subscription') - return - - assert self.relay_thread - assert self.relay_shutdown - assert self.relay_channel - assert self.relay_sub_id - - self.event_queue.unsubscribe(self.relay_sub_id) - self.relay_shutdown.set() - self.relay_thread.join() - logger.debug( - 'Un-subscribed relay sub id %s from core events', - self.relay_sub_id) - - self.relay_thread = None - self.relay_shutdown = None - self.relay_channel = None - self.relay_sub_id = None - - @staticmethod - def relay( - shutdown: threading.Event, - channel: DictQueueType, - callback: Callable[[Dict[str, Any]], None]) -> None: - while not shutdown.is_set(): - try: - ev = channel.get(timeout=1) - callback(ev) - except queue.Empty: - pass - except EOFError: - break - except KeyboardInterrupt: - break diff --git a/proxy/core/event/__init__.py b/proxy/core/event/__init__.py new file mode 100644 index 0000000000..6907dcd55b --- /dev/null +++ b/proxy/core/event/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from .queue import EventQueue +from .names import EventNames, eventNames +from .dispatcher import EventDispatcher +from .subscriber import EventSubscriber + +__all__ = [ + 'eventNames', + 'EventNames', + 'EventQueue', + 'EventDispatcher', + 'EventSubscriber', +] diff --git a/proxy/core/event/dispatcher.py b/proxy/core/event/dispatcher.py new file mode 100644 index 0000000000..f6bb849e5e --- /dev/null +++ b/proxy/core/event/dispatcher.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import queue +import threading +import logging + +from typing import Dict, Any, List + +from ...common.types import DictQueueType + +from .queue import EventQueue +from .names import eventNames + +logger = logging.getLogger(__name__) + + +class EventDispatcher: + """Core EventDispatcher. + + Provides: + 1. A dispatcher module which consumes core events and dispatches + them to EventQueueBasePlugin + 2. A publish utility for publishing core events into + global events queue. + + Direct consuming from global events queue outside of dispatcher + module is not-recommended. Python native multiprocessing queue + doesn't provide a fanout functionality which core dispatcher module + implements so that several plugins can consume same published + event at a time. + + When --enable-events is used, a multiprocessing.Queue is created and + attached to global Flags. This queue can then be used for + dispatching an Event dict object into the queue. + + When --enable-events is used, dispatcher module is automatically + started. Dispatcher module also ensures that queue is not full and + doesn't utilize too much memory in case there are no event plugins + enabled. + """ + + def __init__( + self, + shutdown: threading.Event, + event_queue: EventQueue) -> None: + self.shutdown: threading.Event = shutdown + self.event_queue: EventQueue = event_queue + self.subscribers: Dict[str, DictQueueType] = {} + + def handle_event(self, ev: Dict[str, Any]) -> None: + if ev['event_name'] == eventNames.SUBSCRIBE: + self.subscribers[ev['event_payload']['sub_id']] = \ + ev['event_payload']['channel'] + elif ev['event_name'] == eventNames.UNSUBSCRIBE: + del self.subscribers[ev['event_payload']['sub_id']] + else: + # logger.info(ev) + unsub_ids: List[str] = [] + for sub_id in self.subscribers: + try: + self.subscribers[sub_id].put(ev) + except BrokenPipeError: + unsub_ids.append(sub_id) + for sub_id in unsub_ids: + del self.subscribers[sub_id] + + def run_once(self) -> None: + ev: Dict[str, Any] = self.event_queue.queue.get(timeout=1) + self.handle_event(ev) + + def run(self) -> None: + try: + while not self.shutdown.is_set(): + try: + self.run_once() + except queue.Empty: + pass + except EOFError: + pass + except KeyboardInterrupt: + pass + except Exception as e: + logger.exception('Event dispatcher exception', exc_info=e) diff --git a/proxy/core/event/names.py b/proxy/core/event/names.py new file mode 100644 index 0000000000..b45a70b2d5 --- /dev/null +++ b/proxy/core/event/names.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import NamedTuple + +EventNames = NamedTuple('EventNames', [ + ('SUBSCRIBE', int), + ('UNSUBSCRIBE', int), + ('WORK_STARTED', int), + ('WORK_FINISHED', int), + ('REQUEST_COMPLETE', int), + ('RESPONSE_HEADERS_COMPLETE', int), + ('RESPONSE_CHUNK_RECEIVED', int), + ('RESPONSE_COMPLETE', int), +]) +eventNames = EventNames(1, 2, 3, 4, 5, 6, 7, 8) diff --git a/proxy/core/event/queue.py b/proxy/core/event/queue.py new file mode 100644 index 0000000000..36b246648d --- /dev/null +++ b/proxy/core/event/queue.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import os +import threading +import time +from typing import Dict, Optional, Any + +from ...common.types import DictQueueType + +from .names import eventNames + + +class EventQueue: + """Global event queue. + + Each event contains: + + 1. Request ID - Globally unique + 2. Process ID - Process ID of event publisher. + This will be process id of acceptor workers. + 3. Thread ID - Thread ID of event publisher. + When --threadless is enabled, this value will + be same for all the requests + received by a single acceptor worker. + When --threadless is disabled, this value will be + Thread ID of the thread handling the client request. + 4. Event Timestamp - Time when this event occur + 5. Event Name - One of the defined or custom event name + 6. Event Payload - Optional data associated with the event + 7. Publisher ID (optional) - Optionally, publishing entity unique name / ID + """ + + def __init__(self, queue: DictQueueType) -> None: + self.queue = queue + + def publish( + self, + request_id: str, + event_name: int, + event_payload: Dict[str, Any], + publisher_id: Optional[str] = None + ) -> None: + self.queue.put({ + 'request_id': request_id, + 'process_id': os.getpid(), + 'thread_id': threading.get_ident(), + 'event_timestamp': time.time(), + 'event_name': event_name, + 'event_payload': event_payload, + 'publisher_id': publisher_id, + }) + + def subscribe( + self, + sub_id: str, + channel: DictQueueType) -> None: + """Subscribe to global events.""" + self.queue.put({ + 'event_name': eventNames.SUBSCRIBE, + 'event_payload': {'sub_id': sub_id, 'channel': channel}, + }) + + def unsubscribe( + self, + sub_id: str) -> None: + """Unsubscribe by subscriber id.""" + self.queue.put({ + 'event_name': eventNames.UNSUBSCRIBE, + 'event_payload': {'sub_id': sub_id}, + }) diff --git a/proxy/core/event/subscriber.py b/proxy/core/event/subscriber.py new file mode 100644 index 0000000000..ec6afe6235 --- /dev/null +++ b/proxy/core/event/subscriber.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import queue +import threading +import multiprocessing +import logging +import uuid + +from typing import Dict, Optional, Any, Callable + +from ...common.types import DictQueueType + +from .queue import EventQueue + +logger = logging.getLogger(__name__) + + +class EventSubscriber: + """Core event subscriber.""" + + def __init__(self, event_queue: EventQueue) -> None: + self.manager = multiprocessing.Manager() + self.event_queue = event_queue + self.relay_thread: Optional[threading.Thread] = None + self.relay_shutdown: Optional[threading.Event] = None + self.relay_channel: Optional[DictQueueType] = None + self.relay_sub_id: Optional[str] = None + + def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: + self.relay_shutdown = threading.Event() + self.relay_channel = self.manager.Queue() + self.relay_thread = threading.Thread( + target=self.relay, + args=(self.relay_shutdown, self.relay_channel, callback)) + self.relay_thread.start() + self.relay_sub_id = uuid.uuid4().hex + self.event_queue.subscribe(self.relay_sub_id, self.relay_channel) + logger.debug( + 'Subscribed relay sub id %s from core events', + self.relay_sub_id) + + def unsubscribe(self) -> None: + if self.relay_sub_id is None: + logger.warning('Unsubscribe called without existing subscription') + return + + assert self.relay_thread + assert self.relay_shutdown + assert self.relay_channel + assert self.relay_sub_id + + self.event_queue.unsubscribe(self.relay_sub_id) + self.relay_shutdown.set() + self.relay_thread.join() + logger.debug( + 'Un-subscribed relay sub id %s from core events', + self.relay_sub_id) + + self.relay_thread = None + self.relay_shutdown = None + self.relay_channel = None + self.relay_sub_id = None + + @staticmethod + def relay( + shutdown: threading.Event, + channel: DictQueueType, + callback: Callable[[Dict[str, Any]], None]) -> None: + while not shutdown.is_set(): + try: + ev = channel.get(timeout=1) + callback(ev) + except queue.Empty: + pass + except EOFError: + break + except KeyboardInterrupt: + break diff --git a/version-check.py b/version-check.py index 6732a6e964..c6f88b52cc 100644 --- a/version-check.py +++ b/version-check.py @@ -15,8 +15,10 @@ # # 1. setup.py doesn't import proxy and hence they both use # their own respective __version__ -# 2. TODO: Version is also hardcoded in homebrew stable package -# installer file, but it only needs to match with other -# versions if git branch is master +# 2. TODO: Version is hardcoded in homebrew stable package +# installer file, but it only needs to match with lib +# versions if current git branch is master +# 3. TODO: Version is also hardcoded in README.md flags +# section if lib_version != pkg_version: sys.exit(1) From f75e21c4b159f2ef7ecb421768fbf3a15bf2bee3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 12 Jan 2020 22:56:53 +0200 Subject: [PATCH 100/107] Update setuptools from 44.0.0 to 45.0.0 (#262) --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index eefbb1497a..b7871fc760 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.1 wheel==0.33.6 -setuptools==44.0.0 +setuptools==45.0.0 From 87a54a078102a820d3d13e79d4635730d5a9fda3 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 30 Jan 2020 21:54:03 -0800 Subject: [PATCH 101/107] Fixes #267 (#277) * Fixes #267 * Prepare for v2.1.1 --- proxy/common/version.py | 2 +- proxy/http/parser.py | 5 +++-- proxy/testing/__init__.py | 3 ++- proxy/testing/test_case.py | 3 ++- setup.py | 2 +- tests/http/test_http_parser.py | 5 +++++ 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/proxy/common/version.py b/proxy/common/version.py index 1a23f7912d..170fcbc76b 100644 --- a/proxy/common/version.py +++ b/proxy/common/version.py @@ -8,5 +8,5 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -VERSION = (2, 1, 0) +VERSION = (2, 1, 1) __version__ = '.'.join(map(str, VERSION[0:3])) diff --git a/proxy/http/parser.py b/proxy/http/parser.py index ece1ce0861..1f07bfe15a 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -111,8 +111,9 @@ def set_url(self, url: bytes) -> None: def set_line_attributes(self) -> None: if self.type == httpParserTypes.REQUEST_PARSER: if self.method == httpMethods.CONNECT and self.url: - u = urlparse.urlsplit(b'//' + self.url.path) - self.host, self.port = u.hostname, u.port + self.host = self.url.scheme + self.port = 443 if self.url.path == b'' else \ + int(self.url.path) elif self.url: self.host, self.port = self.url.hostname, self.url.port \ if self.url.port else DEFAULT_HTTP_PORT diff --git a/proxy/testing/__init__.py b/proxy/testing/__init__.py index ba034136b9..232621f0b5 100644 --- a/proxy/testing/__init__.py +++ b/proxy/testing/__init__.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py index cc261f07a5..b1cd9ce8d3 100644 --- a/proxy/testing/test_case.py +++ b/proxy/testing/test_case.py @@ -2,7 +2,8 @@ """ proxy.py ~~~~~~~~ - ⚡⚡⚡ Fast, Lightweight, Programmable Proxy Server in a single Python file. + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. diff --git a/setup.py b/setup.py index 8ff5ec1f2f..5d376b4d5e 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ """ from setuptools import setup, find_packages -VERSION = (2, 1, 0) +VERSION = (2, 1, 1) __version__ = '.'.join(map(str, VERSION[0:3])) __description__ = '''⚡⚡⚡Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on Network monitoring, controls & Application development, testing, debugging.''' diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index 6867749c3b..896b778a9b 100644 --- a/tests/http/test_http_parser.py +++ b/tests/http/test_http_parser.py @@ -22,6 +22,11 @@ class TestHttpParser(unittest.TestCase): def setUp(self) -> None: self.parser = HttpParser(httpParserTypes.REQUEST_PARSER) + def test_urlparse(self) -> None: + self.parser.parse(b'CONNECT httpbin.org:443 HTTP/1.1\r\n') + self.assertEqual(self.parser.host, b'httpbin.org') + self.assertEqual(self.parser.port, 443) + def test_build_request(self) -> None: self.assertEqual( build_http_request( From 434e2502ece36e8724b0698b166a14e6e0755bab Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 30 Jan 2020 22:35:41 -0800 Subject: [PATCH 102/107] Add urlparse fix for Python 3.6.x . Deprecate support for Python 3.5.x (#278) * Add fix required to run on Python 3.6. Python 3.5.x is no longer supported as it reports syntax error and no longer recognize typing syntax * Prepare for v2.1.2 --- proxy/common/version.py | 2 +- proxy/http/parser.py | 10 +++++++--- setup.py | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/proxy/common/version.py b/proxy/common/version.py index 170fcbc76b..913d8044bb 100644 --- a/proxy/common/version.py +++ b/proxy/common/version.py @@ -8,5 +8,5 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -VERSION = (2, 1, 1) +VERSION = (2, 1, 2) __version__ = '.'.join(map(str, VERSION[0:3])) diff --git a/proxy/http/parser.py b/proxy/http/parser.py index 1f07bfe15a..bdd2683aa9 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -111,9 +111,13 @@ def set_url(self, url: bytes) -> None: def set_line_attributes(self) -> None: if self.type == httpParserTypes.REQUEST_PARSER: if self.method == httpMethods.CONNECT and self.url: - self.host = self.url.scheme - self.port = 443 if self.url.path == b'' else \ - int(self.url.path) + if self.url.scheme == b'': + u = urlparse.urlsplit(b'//' + self.url.path) + self.host, self.port = u.hostname, u.port + else: + self.host = self.url.scheme + self.port = 443 if self.url.path == b'' else \ + int(self.url.path) elif self.url: self.host, self.port = self.url.hostname, self.url.port \ if self.url.port else DEFAULT_HTTP_PORT diff --git a/setup.py b/setup.py index 5d376b4d5e..6c73e03fa3 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ """ from setuptools import setup, find_packages -VERSION = (2, 1, 1) +VERSION = (2, 1, 2) __version__ = '.'.join(map(str, VERSION[0:3])) __description__ = '''⚡⚡⚡Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on Network monitoring, controls & Application development, testing, debugging.''' @@ -32,7 +32,7 @@ long_description_content_type='text/markdown', download_url=__download_url__, license=__license__, - python_requires='!=2.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', + python_requires='!=2.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*', zip_safe=False, packages=find_packages(exclude=['tests', 'tests.*']), package_data={'proxy': ['py.typed']}, From 7107e6cf5316b348cd1c9b4aec8f27900311c47c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 31 Jan 2020 09:47:38 +0200 Subject: [PATCH 103/107] Update pytest from 5.3.2 to 5.3.5 (#275) Co-authored-by: Abhinav Singh --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 32925741c0..7742b3d3ce 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,7 +1,7 @@ python-coveralls==2.9.3 coverage==5.0.2 flake8==3.7.9 -pytest==5.3.2 +pytest==5.3.5 pytest-cov==2.8.1 autopep8==1.4.4 mypy==0.761 From c93b80bb252e292f23cb1435a60d077f7f2550a4 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 31 Jan 2020 19:27:27 +0200 Subject: [PATCH 104/107] Update wheel from 0.33.6 to 0.34.2 (#276) Co-authored-by: Abhinav Singh --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index b7871fc760..e2a78f93f9 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.1 -wheel==0.33.6 +wheel==0.34.2 setuptools==45.0.0 From cc896dd10d7546c65c7935ebfd2f6cd91365ae1c Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 31 Jan 2020 19:48:09 +0200 Subject: [PATCH 105/107] Update autopep8 from 1.4.4 to 1.5 (#279) --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 7742b3d3ce..5cbd5b45c8 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -3,7 +3,7 @@ coverage==5.0.2 flake8==3.7.9 pytest==5.3.5 pytest-cov==2.8.1 -autopep8==1.4.4 +autopep8==1.5 mypy==0.761 py-spy==0.3.0 codecov==2.0.15 From fce1b612111d3a2a2c22099cc3d352e951379e65 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 31 Jan 2020 21:48:19 +0200 Subject: [PATCH 106/107] Update setuptools from 45.0.0 to 45.1.0 (#280) Co-authored-by: Abhinav Singh --- requirements-release.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-release.txt b/requirements-release.txt index e2a78f93f9..9560969b7f 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,3 +1,3 @@ twine==3.1.1 wheel==0.34.2 -setuptools==45.0.0 +setuptools==45.1.0 From 44f29840e49c7226c36a64ef30db7edba7b916fd Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 31 Jan 2020 22:34:45 +0200 Subject: [PATCH 107/107] Update coverage from 5.0.2 to 5.0.3 (#281) Co-authored-by: Abhinav Singh --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 5cbd5b45c8..2f63ff8af1 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,5 +1,5 @@ python-coveralls==2.9.3 -coverage==5.0.2 +coverage==5.0.3 flake8==3.7.9 pytest==5.3.5 pytest-cov==2.8.1