Skip to content

Commit

Permalink
Refactor plugin base classes for plugin specific flags (#388)
Browse files Browse the repository at this point in the history
* Update to latest code signing recommendations

* Move HttpProtocolHandlerPlugin into separate file

* Dont add subject attributes if not provided by upstream. Also handle subprocess.TimeoutExpired raised during certificate generation.  Instead of retries, we simply close the connection on timeout

* Remove plugin specific flag initialization methods for now
  • Loading branch information
abhinavsingh authored Jul 4, 2020
1 parent ea227b1 commit 1b0ed92
Show file tree
Hide file tree
Showing 11 changed files with 155 additions and 92 deletions.
4 changes: 3 additions & 1 deletion menubar/proxy.py.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1120;
LastUpgradeCheck = 1120;
LastUpgradeCheck = 1150;
ORGANIZATIONNAME = "Abhinav Singh";
TargetAttributes = {
AD1F92A2238864240088A917 = {
Expand Down Expand Up @@ -432,6 +432,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = proxy.py/proxy_py.entitlements;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"proxy.py/Preview Content\"";
Expand All @@ -455,6 +456,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = proxy.py/proxy_py.entitlements;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"proxy.py/Preview Content\"";
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>proxy.py.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
9 changes: 8 additions & 1 deletion menubar/proxy.py/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// proxy.py
//
// Created by Abhinav Singh on 11/22/19.
// Copyright © 2019 Abhinav Singh. All rights reserved.
// Copyright © 2013-present by Abhinav Singh and contributors.
// All rights reserved.
//

import Cocoa
Expand Down Expand Up @@ -41,3 +42,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusItem.menu = menu
}
}

struct AppDelegate_Previews: PreviewProvider {
static var previews: some View {
/*@START_MENU_TOKEN@*/Text("Hello, World!")/*@END_MENU_TOKEN@*/
}
}
3 changes: 2 additions & 1 deletion menubar/proxy.py/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// proxy.py
//
// Created by Abhinav Singh on 11/22/19.
// Copyright © 2019 Abhinav Singh. All rights reserved.
// Copyright © 2013-present by Abhinav Singh and contributors.
// All rights reserved.
//

import SwiftUI
Expand Down
4 changes: 4 additions & 0 deletions proxy/common/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ def initialize(
'A future version of pip will drop support for Python 2.7.')
sys.exit(1)

# Initialize core flags.
parser = Flags.init_parser()
# Parse flags
args = parser.parse_args(input_args)

# Print version and exit
Expand All @@ -159,6 +161,7 @@ def initialize(
# Setup limits
Flags.set_open_file_limit(args.open_file_limit)

# Prepare list of plugins to load based upon --enable-* and --disable-* flags
default_plugins: List[Tuple[str, bool]] = []
if args.enable_dashboard:
default_plugins.append((PLUGIN_WEB_SERVER, True))
Expand All @@ -179,6 +182,7 @@ def initialize(
if args.pac_file is not None:
default_plugins.append((PLUGIN_PAC_FILE, True))

# Load default plugins along with user provided --plugins
plugins = Flags.load_plugins(
bytes_(
'%s,%s' %
Expand Down
81 changes: 3 additions & 78 deletions proxy/http/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
import contextlib
import errno
import logging
from abc import ABC, abstractmethod

from typing import Tuple, List, Union, Optional, Generator, Dict
from uuid import UUID

from .plugin import HttpProtocolHandlerPlugin
from .parser import HttpParser, httpParserStates, httpParserTypes
from .exception import HttpProtocolException

Expand All @@ -30,83 +32,6 @@
logger = logging.getLogger(__name__)


class HttpProtocolHandlerPlugin(ABC):
"""Base HttpProtocolHandler Plugin class.
NOTE: This is an internal plugin and in most cases only useful for core contributors.
If you are looking for proxy server plugins see `<proxy.HttpProxyBasePlugin>`.
Implements various lifecycle events for an accepted client connection.
Following events are of interest:
1. Client Connection Accepted
A new plugin instance is created per accepted client connection.
Add your logic within __init__ constructor for any per connection setup.
2. Client Request Chunk Received
on_client_data is called for every chunk of data sent by the client.
3. Client Request Complete
on_request_complete is called once client request has completed.
4. Server Response Chunk Received
on_response_chunk is called for every chunk received from the server.
5. Client Connection Closed
Add your logic within `on_client_connection_close` for any per connection teardown.
"""

def __init__(
self,
uid: UUID,
flags: Flags,
client: TcpClientConnection,
request: HttpParser,
event_queue: EventQueue):
self.uid: UUID = uid
self.flags: Flags = flags
self.client: TcpClientConnection = client
self.request: HttpParser = request
self.event_queue = event_queue
super().__init__()

def name(self) -> str:
"""A unique name for your plugin.
Defaults to name of the class. This helps plugin developers to directly
access a specific plugin by its name."""
return self.__class__.__name__

@abstractmethod
def get_descriptors(
self) -> Tuple[List[socket.socket], List[socket.socket]]:
return [], [] # pragma: no cover

@abstractmethod
def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool:
return False # pragma: no cover

@abstractmethod
def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool:
return False # pragma: no cover

@abstractmethod
def on_client_data(self, raw: memoryview) -> Optional[memoryview]:
return raw # pragma: no cover

@abstractmethod
def on_request_complete(self) -> Union[socket.socket, bool]:
"""Called right after client request parser has reached COMPLETE state."""
return False # pragma: no cover

@abstractmethod
def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]:
"""Handle data chunks as received from the server.
Return optionally modified chunk to return back to client."""
return chunk # pragma: no cover

@abstractmethod
def on_client_connection_close(self) -> None:
pass # pragma: no cover


class HttpProtocolHandler(ThreadlessWork):
"""HTTP, HTTPS, HTTP2, WebSockets protocol handler.
Expand Down
99 changes: 99 additions & 0 deletions proxy/http/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
proxy.py
~~~~~~~~
⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on
Network monitoring, controls & Application development, testing, debugging.
:copyright: (c) 2013-present by Abhinav Singh and contributors.
:license: BSD, see LICENSE for more details.
"""
import socket

from abc import ABC, abstractmethod
from uuid import UUID
from typing import Tuple, List, Union, Optional

from .parser import HttpParser

from ..common.flags import Flags
from ..common.types import HasFileno
from ..core.event import EventQueue
from ..core.connection import TcpClientConnection


class HttpProtocolHandlerPlugin(ABC):
"""Base HttpProtocolHandler Plugin class.
NOTE: This is an internal plugin and in most cases only useful for core contributors.
If you are looking for proxy server plugins see `<proxy.HttpProxyBasePlugin>`.
Implements various lifecycle events for an accepted client connection.
Following events are of interest:
1. Client Connection Accepted
A new plugin instance is created per accepted client connection.
Add your logic within __init__ constructor for any per connection setup.
2. Client Request Chunk Received
on_client_data is called for every chunk of data sent by the client.
3. Client Request Complete
on_request_complete is called once client request has completed.
4. Server Response Chunk Received
on_response_chunk is called for every chunk received from the server.
5. Client Connection Closed
Add your logic within `on_client_connection_close` for any per connection teardown.
"""

def __init__(
self,
uid: UUID,
flags: Flags,
client: TcpClientConnection,
request: HttpParser,
event_queue: EventQueue):
self.uid: UUID = uid
self.flags: Flags = flags
self.client: TcpClientConnection = client
self.request: HttpParser = request
self.event_queue = event_queue
super().__init__()

def name(self) -> str:
"""A unique name for your plugin.
Defaults to name of the class. This helps plugin developers to directly
access a specific plugin by its name."""
return self.__class__.__name__

@abstractmethod
def get_descriptors(
self) -> Tuple[List[socket.socket], List[socket.socket]]:
return [], [] # pragma: no cover

@abstractmethod
def write_to_descriptors(self, w: List[Union[int, HasFileno]]) -> bool:
return False # pragma: no cover

@abstractmethod
def read_from_descriptors(self, r: List[Union[int, HasFileno]]) -> bool:
return False # pragma: no cover

@abstractmethod
def on_client_data(self, raw: memoryview) -> Optional[memoryview]:
return raw # pragma: no cover

@abstractmethod
def on_request_complete(self) -> Union[socket.socket, bool]:
"""Called right after client request parser has reached COMPLETE state."""
return False # pragma: no cover

@abstractmethod
def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]:
"""Handle data chunks as received from the server.
Return optionally modified chunk to return back to client."""
return chunk # pragma: no cover

@abstractmethod
def on_client_connection_close(self) -> None:
pass # pragma: no cover
29 changes: 20 additions & 9 deletions proxy/http/proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""
import logging
import threading
import subprocess
import os
import ssl
import socket
Expand All @@ -18,7 +19,7 @@
from typing import Optional, List, Union, Dict, cast, Any, Tuple

from .plugin import HttpProxyBasePlugin
from ..handler import HttpProtocolHandlerPlugin
from ..plugin import HttpProtocolHandlerPlugin
from ..exception import HttpProtocolException, ProxyConnectionFailed, ProxyAuthenticationFailed
from ..codes import httpStatusCodes
from ..parser import HttpParser, httpParserStates, httpParserTypes
Expand Down Expand Up @@ -287,6 +288,9 @@ def on_request_complete(self) -> Union[socket.socket, bool]:
# wrap_client also flushes client data before wrapping
# sending to client can raise, handle expected exceptions
self.wrap_client()
except subprocess.TimeoutExpired as e: # Popen communicate timeout
logger.exception('TimeoutExpired during certificate generation', exc_info=e)
return True
except BrokenPipeError:
logger.error(
'BrokenPipeError when wrapping client')
Expand Down Expand Up @@ -372,13 +376,19 @@ def gen_ca_signed_certificate(self, cert_file_path: str, certificate: Dict[str,
'{0}.{1}'.format(text_(self.request.host), 'pub'))
private_key_path = self.flags.ca_signing_key_file
private_key_password = ''
subject = '/CN={0}/C={1}/ST={2}/L={3}/O={4}/OU={5}'.format(
upstream_subject.get('commonName', text_(self.request.host)),
upstream_subject.get('countryName', 'NA'),
upstream_subject.get('stateOrProvinceName', 'Unavailable'),
upstream_subject.get('localityName', 'Unavailable'),
upstream_subject.get('organizationName', 'Unavailable'),
upstream_subject.get('organizationalUnitName', 'Unavailable'))
# Build certificate subject
keys = {
'CN': 'commonName',
'C': 'countryName',
'ST': 'stateOrProvinceName',
'L': 'localityName',
'O': 'organizationName',
'OU': 'organizationalUnitName',
}
subject = ''
for key in keys:
if upstream_subject.get(keys[key], None):
subject += '/{0}={1}'.format(key, upstream_subject.get(keys[key]))
alt_subj_names = [text_(self.request.host), ]
validity_in_days = 365 * 2
timeout = 10
Expand Down Expand Up @@ -458,9 +468,10 @@ def wrap_client(self) -> None:
self.client._conn = ssl.wrap_socket(
self.client.connection,
server_side=True,
# ca_certs=self.flags.ca_cert_file,
certfile=generated_cert,
keyfile=self.flags.ca_signing_key_file,
ssl_version=ssl.PROTOCOL_TLSv1_2)
ssl_version=ssl.PROTOCOL_TLS)
self.client.connection.setblocking(False)
logger.debug(
'TLS interception using %s', generated_cert)
Expand Down
2 changes: 1 addition & 1 deletion proxy/http/server/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ..websocket import WebsocketFrame, websocketOpcodes
from ..codes import httpStatusCodes
from ..parser import HttpParser, httpParserStates, httpParserTypes
from ..handler import HttpProtocolHandlerPlugin
from ..plugin import HttpProtocolHandlerPlugin

from ...common.utils import bytes_, text_, build_http_response, build_websocket_handshake_response
from ...common.constants import PROXY_AGENT_HEADER_VALUE
Expand Down
2 changes: 1 addition & 1 deletion tests/http/test_http_proxy_tls_interception.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def mock_connection() -> Any:
keyfile=self.flags.ca_signing_key_file,
certfile=HttpProxyPlugin.generated_cert_file_path(
self.flags.ca_cert_dir, host),
ssl_version=ssl.PROTOCOL_TLSv1_2
ssl_version=ssl.PROTOCOL_TLS
)
self.assertEqual(self._conn.setblocking.call_count, 2)
self.assertEqual(
Expand Down

0 comments on commit 1b0ed92

Please sign in to comment.