Skip to content

Commit 3cfce52

Browse files
Add TcpUpstreamConnectionHandler class (#760)
* Add `TcpUpstreamConnectionHandler` which can be used as standalone or as mixin * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * `TcpUpstreamConnectionHandler` is now an abstract class * Fix mypy * `nitpick_ignore` the `proxy.core.base.tcp_upstream.TcpUpstreamConnectionHandler` class * Add mypy exception for now * Fix flake * Fix docstring Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 44c095a commit 3cfce52

File tree

11 files changed

+187
-121
lines changed

11 files changed

+187
-121
lines changed

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
(_any_role, 'HttpProtocolHandler'),
257257
(_any_role, 'multiprocessing.Manager'),
258258
(_any_role, 'work_klass'),
259+
(_any_role, 'proxy.core.base.tcp_upstream.TcpUpstreamConnectionHandler'),
259260
(_py_class_role, 'CacheStore'),
260261
(_py_class_role, 'HttpParser'),
261262
(_py_class_role, 'HttpProtocolHandlerPlugin'),

proxy/core/base/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
"""
1515
from .tcp_server import BaseTcpServerHandler
1616
from .tcp_tunnel import BaseTcpTunnelHandler
17+
from .tcp_upstream import TcpUpstreamConnectionHandler
1718

1819
__all__ = [
1920
'BaseTcpServerHandler',
2021
'BaseTcpTunnelHandler',
22+
'TcpUpstreamConnectionHandler',
2123
]

proxy/core/base/tcp_server.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ class BaseTcpServerHandler(Work):
4545
a. handle_data(data: memoryview) implementation
4646
b. Optionally, also implement other Work method
4747
e.g. initialize, is_inactive, shutdown
48-
4948
"""
5049

5150
def __init__(self, *args: Any, **kwargs: Any) -> None:

proxy/core/base/tcp_upstream.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
proxy.py
4+
~~~~~~~~
5+
⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on
6+
Network monitoring, controls & Application development, testing, debugging.
7+
8+
:copyright: (c) 2013-present by Abhinav Singh and contributors.
9+
:license: BSD, see LICENSE for more details.
10+
"""
11+
from abc import ABC, abstractmethod
12+
13+
import ssl
14+
import socket
15+
import logging
16+
17+
from typing import Tuple, List, Optional, Any
18+
19+
from ...common.types import Readables, Writables
20+
from ...core.connection import TcpServerConnection
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class TcpUpstreamConnectionHandler(ABC):
26+
""":class:`~proxy.core.base.TcpUpstreamConnectionHandler` can
27+
be used to insert an upstream server connection lifecycle within
28+
asynchronous proxy.py lifecycle.
29+
30+
Call `initialize_upstream` to initialize the upstream connection object.
31+
Then, directly use ``self.upstream`` object within your class.
32+
33+
.. spelling::
34+
35+
tcp
36+
"""
37+
38+
def __init__(self, *args: Any, **kwargs: Any) -> None:
39+
# This is currently a hack, see comments below for rationale,
40+
# will be fixed later.
41+
super().__init__(*args, **kwargs) # type: ignore
42+
self.upstream: Optional[TcpServerConnection] = None
43+
# TODO: Currently, :class:`~proxy.core.base.TcpUpstreamConnectionHandler`
44+
# is used within :class:`~proxy.plugin.ReverseProxyPlugin` and
45+
# :class:`~proxy.plugin.ProxyPoolPlugin`.
46+
#
47+
# For both of which we expect a 4-tuple as arguments
48+
# containing (uuid, flags, client, event_queue).
49+
# We really don't need the rest of the args here.
50+
# May be uuid? May be event_queue in the future.
51+
# But certainly we don't not client here.
52+
# A separate tunnel class must be created which handles
53+
# client connection too.
54+
#
55+
# Both :class:`~proxy.plugin.ReverseProxyPlugin` and
56+
# :class:`~proxy.plugin.ProxyPoolPlugin` are currently
57+
# calling client queue within `handle_upstream_data` callback.
58+
#
59+
# This can be abstracted out too.
60+
self.server_recvbuf_size = args[1].server_recvbuf_size
61+
self.total_size = 0
62+
63+
@abstractmethod
64+
def handle_upstream_data(self, raw: memoryview) -> None:
65+
pass
66+
67+
def initialize_upstream(self, addr: str, port: int) -> None:
68+
self.upstream = TcpServerConnection(addr, port)
69+
70+
def get_descriptors(self) -> Tuple[List[socket.socket], List[socket.socket]]:
71+
if not self.upstream:
72+
return [], []
73+
return [self.upstream.connection], [self.upstream.connection] if self.upstream.has_buffer() else []
74+
75+
def read_from_descriptors(self, r: Readables) -> bool:
76+
if self.upstream and self.upstream.connection in r:
77+
try:
78+
raw = self.upstream.recv(self.server_recvbuf_size)
79+
if raw is not None:
80+
self.total_size += len(raw)
81+
self.handle_upstream_data(raw)
82+
else:
83+
return True # Teardown because upstream proxy closed the connection
84+
except ssl.SSLWantReadError:
85+
logger.info('Upstream SSLWantReadError, will retry')
86+
return False
87+
except ConnectionResetError:
88+
logger.debug('Connection reset by upstream')
89+
return True
90+
return False
91+
92+
def write_to_descriptors(self, w: Writables) -> bool:
93+
if self.upstream and self.upstream.connection in w and self.upstream.has_buffer():
94+
try:
95+
self.upstream.flush()
96+
except ssl.SSLWantWriteError:
97+
logger.info('Upstream SSLWantWriteError, will retry')
98+
return False
99+
except BrokenPipeError:
100+
logger.debug('BrokenPipeError when flushing to upstream')
101+
return True
102+
return False

proxy/http/server/pac_plugin.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from .plugin import HttpWebServerBasePlugin
2121
from .protocols import httpProtocolTypes
2222

23-
from ..websocket import WebsocketFrame
2423
from ..parser import HttpParser
2524

2625
from ...common.utils import bytes_, text_, build_http_response
@@ -64,15 +63,6 @@ def handle_request(self, request: HttpParser) -> None:
6463
if self.flags.pac_file and self.pac_file_response:
6564
self.client.queue(self.pac_file_response)
6665

67-
def on_websocket_open(self) -> None:
68-
pass # pragma: no cover
69-
70-
def on_websocket_message(self, frame: WebsocketFrame) -> None:
71-
pass # pragma: no cover
72-
73-
def on_client_connection_close(self) -> None:
74-
pass # pragma: no cover
75-
7666
def cache_pac_file_response(self) -> None:
7767
if self.flags.pac_file:
7868
try:

proxy/http/server/plugin.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,19 @@ def on_client_connection_close(self) -> None:
9595
"""Client has closed the connection, do any clean up task now."""
9696
pass
9797

98-
@abstractmethod
98+
# No longer abstract since v2.4.0
99+
#
100+
# @abstractmethod
99101
def on_websocket_open(self) -> None:
100102
"""Called when websocket handshake has finished."""
101-
raise NotImplementedError() # pragma: no cover
103+
pass # pragma: no cover
102104

103-
@abstractmethod
105+
# No longer abstract since v2.4.0
106+
#
107+
# @abstractmethod
104108
def on_websocket_message(self, frame: WebsocketFrame) -> None:
105109
"""Handle websocket frame."""
106-
raise NotImplementedError() # pragma: no cover
110+
return None # pragma: no cover
107111

108112
# Deprecated since v2.4.0
109113
#

proxy/http/server/web.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,5 @@ def on_client_connection_close(self) -> None:
327327
if not log_handled:
328328
self.access_log(context)
329329

330-
# TODO: Allow plugins to customize access_log, similar
331-
# to how proxy server plugins are able to do it.
332330
def access_log(self, context: Dict[str, Any]) -> None:
333331
logger.info(DEFAULT_WEB_ACCESS_LOG_FORMAT.format_map(context))

proxy/http/url.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from typing import Optional, Tuple
1717

1818
from ..common.constants import COLON, SLASH
19+
from ..common.utils import text_
1920

2021

2122
class Url:
@@ -36,6 +37,18 @@ def __init__(
3637
self.port: Optional[int] = port
3738
self.remainder: Optional[bytes] = remainder
3839

40+
def __str__(self) -> str:
41+
url = ''
42+
if self.scheme:
43+
url += '{0}://'.format(text_(self.scheme))
44+
if self.hostname:
45+
url += text_(self.hostname)
46+
if self.port:
47+
url += ':{0}'.format(self.port)
48+
if self.remainder:
49+
url += text_(self.remainder)
50+
return url
51+
3952
@classmethod
4053
def from_bytes(cls, raw: bytes) -> 'Url':
4154
"""A URL within proxy.py core can have several styles,
@@ -57,7 +70,9 @@ def from_bytes(cls, raw: bytes) -> 'Url':
5770
return cls(remainder=raw)
5871
if sraw.startswith('https://') or sraw.startswith('http://'):
5972
is_https = sraw.startswith('https://')
60-
rest = raw[len(b'https://'):] if is_https else raw[len(b'http://'):]
73+
rest = raw[len(b'https://'):] \
74+
if is_https \
75+
else raw[len(b'http://'):]
6176
parts = rest.split(SLASH)
6277
host, port = Url.parse_host_and_port(parts[0])
6378
return cls(

proxy/plugin/proxy_pool.py

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,18 @@
99
:license: BSD, see LICENSE for more details.
1010
"""
1111
import random
12-
import socket
1312
import logging
1413

15-
from typing import Dict, List, Optional, Any, Tuple
14+
from typing import Dict, List, Optional, Any
1615

1716
from ..common.flag import flags
18-
from ..common.types import Readables, Writables
1917

2018
from ..http import Url, httpMethods
2119
from ..http.parser import HttpParser
2220
from ..http.exception import HttpProtocolException
2321
from ..http.proxy import HttpProxyBasePlugin
2422

25-
from ..core.connection.server import TcpServerConnection
23+
from ..core.base import TcpUpstreamConnectionHandler
2624

2725
logger = logging.getLogger(__name__)
2826

@@ -54,50 +52,21 @@
5452
)
5553

5654

57-
class ProxyPoolPlugin(HttpProxyBasePlugin):
55+
class ProxyPoolPlugin(TcpUpstreamConnectionHandler, HttpProxyBasePlugin):
5856
"""Proxy pool plugin simply acts as a proxy adapter for proxy.py itself.
5957
6058
Imagine this plugin as setting up proxy settings for proxy.py instance itself.
6159
All incoming client requests are proxied to configured upstream proxies."""
6260

6361
def __init__(self, *args: Any, **kwargs: Any) -> None:
6462
super().__init__(*args, **kwargs)
65-
self.upstream: Optional[TcpServerConnection] = None
6663
# Cached attributes to be used during access log override
6764
self.request_host_port_path_method: List[Any] = [
6865
None, None, None, None,
6966
]
70-
self.total_size = 0
71-
72-
def get_descriptors(self) -> Tuple[List[socket.socket], List[socket.socket]]:
73-
if not self.upstream:
74-
return [], []
75-
return [self.upstream.connection], [self.upstream.connection] if self.upstream.has_buffer() else []
76-
77-
def read_from_descriptors(self, r: Readables) -> bool:
78-
# Read from upstream proxy and queue for client
79-
if self.upstream and self.upstream.connection in r:
80-
try:
81-
raw = self.upstream.recv(self.flags.server_recvbuf_size)
82-
if raw is not None:
83-
self.total_size += len(raw)
84-
self.client.queue(raw)
85-
else:
86-
return True # Teardown because upstream proxy closed the connection
87-
except ConnectionResetError:
88-
logger.debug('Connection reset by upstream proxy')
89-
return True
90-
return False # Do not teardown connection
91-
92-
def write_to_descriptors(self, w: Writables) -> bool:
93-
# Flush queued data to upstream proxy now
94-
if self.upstream and self.upstream.connection in w and self.upstream.has_buffer():
95-
try:
96-
self.upstream.flush()
97-
except BrokenPipeError:
98-
logger.debug('BrokenPipeError when flushing to upstream proxy')
99-
return True
100-
return False
67+
68+
def handle_upstream_data(self, raw: memoryview) -> None:
69+
self.client.queue(raw)
10170

10271
def before_upstream_connection(
10372
self, request: HttpParser,
@@ -109,12 +78,14 @@ def before_upstream_connection(
10978
# must be bootstrapped within it's own re-usable and gc'd pool, to avoid establishing
11079
# a fresh upstream proxy connection for each client request.
11180
#
81+
# See :class:`~proxy.core.connection.pool.ConnectionPool` which is a work
82+
# in progress for SSL cache handling.
83+
#
11284
# Implement your own logic here e.g. round-robin, least connection etc.
11385
endpoint = random.choice(self.flags.proxy_pool)[0].split(':')
11486
logger.debug('Using endpoint: {0}:{1}'.format(*endpoint))
115-
self.upstream = TcpServerConnection(
116-
endpoint[0], int(endpoint[1]),
117-
)
87+
self.initialize_upstream(endpoint[0], int(endpoint[1]))
88+
assert self.upstream
11889
try:
11990
self.upstream.connect()
12091
except ConnectionRefusedError:

0 commit comments

Comments
 (0)