Repository: abhinavsingh/proxy.py
Pull request #282 (2020-02-01 01:12:51)

proxy.py v2.1.2


Patch:
diff --git a/.gitignore b/.gitignore
index 45423f1..343de58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ proxy.py.iml
 *.csr
 *.crt
 *.key
+*.pem
 venv*
 
 cover
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..84e4f57
--- /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
diff --git a/Makefile b/Makefile
index 0dfd26e..17152b4 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
@@ -30,18 +31,34 @@ 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-version:
+	python version-check.py
 
 lib-clean:
 	find . -name '*.pyc' -exec rm -f {} +
@@ -59,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 6b7107f..d093a77 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/)
@@ -84,10 +88,11 @@ 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)
+    * [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)
@@ -1157,7 +1162,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.
@@ -1168,7 +1173,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.
@@ -1190,9 +1195,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'/')
@@ -1200,7 +1205,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'/',
@@ -1209,7 +1214,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
@@ -1219,19 +1224,54 @@ 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) for
+method parameters and [test_pki.py](https://github.com/abhinavsingh/proxy.py/blob/develop/tests/common/test_pki.py)
+for usage examples.
 
-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
 
@@ -1255,6 +1295,19 @@ 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`:
@@ -1373,7 +1426,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/helper/homebrew/stable/proxy.rb b/helper/homebrew/stable/proxy.rb
index 2abed63..c13d221 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"
 
diff --git a/proxy/common/pki.py b/proxy/common/pki.py
index 024e447..0f1030b 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 typing import List, Generator, Optional, Tuple
 
 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 36d645c..913d804 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, 2)
 __version__ = '.'.join(map(str, VERSION[0:3]))
diff --git a/proxy/core/event.py b/proxy/core/event.py
deleted file mode 100644
index 00fec14..0000000
--- 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 0000000..6907dcd
--- /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 0000000..f6bb849
--- /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 0000000..b45a70b
--- /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 0000000..36b2466
--- /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 0000000..ec6afe6
--- /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/proxy/http/parser.py b/proxy/http/parser.py
index ece1ce0..bdd2683 100644
--- a/proxy/http/parser.py
+++ b/proxy/http/parser.py
@@ -111,8 +111,13 @@ class HttpParser:
     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
+                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/proxy/py.typed b/proxy/py.typed
new file mode 100644
index 0000000..d5b3fa9
--- /dev/null
+++ b/proxy/py.typed
@@ -0,0 +1,1 @@
+# Marker file for PEP 561.  The proxy package uses inline types.
diff --git a/requirements-release.txt b/requirements-release.txt
index f7e543e..9560969 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
+wheel==0.34.2
+setuptools==45.1.0
diff --git a/setup.py b/setup.py
index 5e7b9d9..6c73e03 100644
--- a/setup.py
+++ b/setup.py
@@ -10,89 +10,92 @@
 """
 from setuptools import setup, find_packages
 
-VERSION = (2, 0, 0)
+VERSION = (2, 1, 2)
 __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.*, !=3.5.*',
+        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/version-check.py b/version-check.py
new file mode 100644
index 0000000..c6f88b5
--- /dev/null
+++ b/version-check.py
@@ -0,0 +1,24 @@
+"""
+    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 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)


Test patch:
diff --git a/.github/workflows/test-brew.yml b/.github/workflows/test-brew.yml
index 10d217a..ff9b992 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 c3711e7..ae1bb10 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 ddb7f60..7e1d433 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 007b3c5..65059ec 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:
diff --git a/proxy/testing/__init__.py b/proxy/testing/__init__.py
index ba03413..232621f 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 cc261f0..b1cd9ce 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/requirements-testing.txt b/requirements-testing.txt
index d9825e2..2f63ff8 100644
--- a/requirements-testing.txt
+++ b/requirements-testing.txt
@@ -1,11 +1,11 @@
 python-coveralls==2.9.3
-coverage==5.0.1
+coverage==5.0.3
 flake8==3.7.9
-pytest==5.3.2
+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
-tox==3.14.2
+tox==3.14.3
 mccabe==0.6.1
diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py
index ee13621..896b778 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(
@@ -134,8 +139,7 @@ class TestHttpParser(unittest.TestCase):
         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 +193,7 @@ class TestHttpParser(unittest.TestCase):
         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 +210,7 @@ class TestHttpParser(unittest.TestCase):
         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 +218,8 @@ class TestHttpParser(unittest.TestCase):
 
         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 +241,10 @@ class TestHttpParser(unittest.TestCase):
         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 +378,8 @@ class TestHttpParser(unittest.TestCase):
             b'<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">\n' +
             b'<TITLE>301 Moved</TITLE></HEAD><BODY>\n<H1>301 Moved</H1>\nThe document has moved\n' +
             b'<A HREF="http://www.google.com/">here</A>.\r\n</BODY></HTML>\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 +396,8 @@ class TestHttpParser(unittest.TestCase):
             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)