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: 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/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 diff --git a/Makefile b/Makefile index 0dfd26ed85..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 @@ -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 6b7107f105..d093a77671 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ [](https://github.com/abhinavsingh/proxy.py) [](https://opensource.org/licenses/BSD-3-Clause) -[](https://travis-ci.org/abhinavsingh/proxy.py/) -[](https://github.com/abhinavsingh/proxy.py) [](https://pypi.org/project/proxy.py/) [](https://hub.docker.com/r/abhinavsingh/proxy.py) +[](https://github.com/abhinavsingh/proxy.py) + +[](https://github.com/abhinavsingh/proxy.py/actions) +[](https://github.com/abhinavsingh/proxy.py/actions) +[](https://github.com/abhinavsingh/proxy.py/actions) +[](https://github.com/abhinavsingh/proxy.py/actions) [](https://codecov.io/gh/abhinavsingh/proxy.py) [](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 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" 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..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, 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 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/proxy/http/parser.py b/proxy/http/parser.py index ece1ce0861..bdd2683aa9 100644 --- a/proxy/http/parser.py +++ b/proxy/http/parser.py @@ -111,8 +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: - 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 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/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/requirements-release.txt b/requirements-release.txt index f7e543e1d1..9560969b7f 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/requirements-testing.txt b/requirements-testing.txt index d9825e2841..2f63ff8af1 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/setup.py b/setup.py index 5e7b9d98e0..6c73e03fa3 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/tests/http/test_http_parser.py b/tests/http/test_http_parser.py index ee13621125..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( @@ -134,8 +139,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 +193,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 +210,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 +218,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 +241,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 +378,8 @@ def test_response_parse(self) -> None: b'
\n' + b'