Skip to content

Commit 7448c44

Browse files
authored
v3.4.0 (#638)
* Build docker from 3.10-alpine * Bump version to 3.4.0 * Add instructions for how to run dashboard * Order of menu * Override dashboard png path until submitted * Add some doc string for top-level Proxy class. Also some TODOs and warnings regarding PID file overwrite * Allow HttpProxyBasePlugin implementations to register custom descriptors for read/write events * Remove hardcoded adblock regex into json config. Update upstream filter to block facebook, not google * ProxyPoolPlugin and ReverseProxyPlugin must now be updated to use get/read/write descriptor APIs * Add get/read/write descriptor API for HttpWebServerBasePlugin too * Surface actual listening port via flags.port
1 parent 3d8bf05 commit 7448c44

21 files changed

+308
-124
lines changed

Dashboard.png

1.05 MB
Loading

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.8-alpine as base
1+
FROM python:3.10-alpine as base
22
FROM base as builder
33

44
COPY requirements.txt /app/

README.md

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
[![Python 3.x](https://img.shields.io/static/v1?label=Python&message=3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10&color=blue)](https://www.python.org/)
2424
[![Checked with mypy](https://img.shields.io/static/v1?label=MyPy&message=checked&color=blue)](http://mypy-lang.org/)
2525

26-
[![Become a Backer](https://opencollective.com/proxypy/tiers/backer.svg?avatarHeight=72)](https://opencollective.com/proxypy)
27-
2826
# Table of Contents
2927

3028
- [Features](#features)
@@ -94,6 +92,8 @@
9492
- [Public Key Infrastructure](#pki)
9593
- [API Usage](#api-usage)
9694
- [CLI Usage](#cli-usage)
95+
- [Run Dashboard](#run-dashboard)
96+
- [Inspect Traffic](#inspect-traffic)
9797
- [Frequently Asked Questions](#frequently-asked-questions)
9898
- [Threads vs Threadless](#threads-vs-threadless)
9999
- [SyntaxError: invalid syntax](#syntaxerror-invalid-syntax)
@@ -1537,6 +1537,45 @@ FILE
15371537
/Users/abhinav/Dev/proxy.py/proxy/__init__.py
15381538
```
15391539
1540+
# Run Dashboard
1541+
1542+
Dashboard is currently under development and not yet bundled with `pip` packages. To run dashboard, you must checkout the source.
1543+
1544+
Dashboard is written in Typescript and SCSS, so let's build it first using:
1545+
1546+
```bash
1547+
$ make dashboard
1548+
```
1549+
1550+
Now start `proxy.py` with dashboard plugin and by overriding root directory for static server:
1551+
1552+
```bash
1553+
$ proxy --enable-dashboard --static-server-dir dashboard/public
1554+
...[redacted]... - Loaded plugin proxy.http.server.HttpWebServerPlugin
1555+
...[redacted]... - Loaded plugin proxy.dashboard.dashboard.ProxyDashboard
1556+
...[redacted]... - Loaded plugin proxy.dashboard.inspect_traffic.InspectTrafficPlugin
1557+
...[redacted]... - Loaded plugin proxy.http.inspector.DevtoolsProtocolPlugin
1558+
...[redacted]... - Loaded plugin proxy.http.proxy.HttpProxyPlugin
1559+
...[redacted]... - Listening on ::1:8899
1560+
...[redacted]... - Core Event enabled
1561+
```
1562+
1563+
Currently, enabling dashboard will also enable all the dashboard plugins.
1564+
1565+
Visit dashboard:
1566+
1567+
```bash
1568+
$ open http://localhost:8899/dashboard/
1569+
```
1570+
1571+
## Inspect Traffic
1572+
1573+
Wait for embedded `Chrome Dev Console` to load. Currently, detail about all traffic flowing through `proxy.py` is pushed to the `Inspect Traffic` tab. However, received payloads are not yet integrated with the embedded dev console.
1574+
1575+
Current functionality can be verified by opening the `Dev Console` of dashboard and inspecting the websocket connection that dashboard established with the `proxy.py` server.
1576+
1577+
[![Proxy.Py Dashboard Inspect Traffic](https://raw.githubusercontent.com/abhinavsingh/proxy.py/v3.4.0/Dashboard.png)](https://github.com/abhinavsingh/proxy.py)
1578+
15401579
# Frequently Asked Questions
15411580
15421581
## Threads vs Threadless
@@ -1676,7 +1715,7 @@ usage: proxy [-h] [--threadless] [--backlog BACKLOG] [--enable-events] [--hostna
16761715
[--ca-file CA_FILE] [--ca-signing-key-file CA_SIGNING_KEY_FILE] [--cert-file CERT_FILE] [--disable-headers DISABLE_HEADERS] [--server-recvbuf-size SERVER_RECVBUF_SIZE] [--basic-auth BASIC_AUTH]
16771716
[--cache-dir CACHE_DIR] [--static-server-dir STATIC_SERVER_DIR] [--pac-file PAC_FILE] [--pac-file-url-path PAC_FILE_URL_PATH] [--filtered-client-ips FILTERED_CLIENT_IPS]
16781717
1679-
proxy.py v2.3.1
1718+
proxy.py v2.4.0
16801719
16811720
options:
16821721
-h, --help show this help message and exit

proxy/common/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
:copyright: (c) 2013-present by Abhinav Singh and contributors.
99
:license: BSD, see LICENSE for more details.
1010
"""
11-
VERSION = (2, 3, 1)
11+
VERSION = (2, 4, 0)
1212
__version__ = '.'.join(map(str, VERSION[0:3]))

proxy/core/acceptor/pool.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,10 @@
6363

6464

6565
class AcceptorPool:
66-
"""AcceptorPool.
67-
68-
Pre-spawns worker processes to utilize all cores available on the system.
66+
"""AcceptorPool pre-spawns worker processes to utilize all cores available on the system.
6967
A server socket is initialized and dispatched over a pipe to these workers.
70-
Each worker process then accepts new client connection.
68+
Each worker process then concurrently accepts new client connection over
69+
the initialized server socket.
7170
7271
Example usage:
7372
@@ -83,7 +82,9 @@ class AcceptorPool:
8382
8483
Optionally, AcceptorPool also initialize a global event queue.
8584
It is a multiprocess safe queue which can be used to build pubsub patterns
86-
for message sharing or signaling within proxy.py.
85+
for message sharing or signaling.
86+
87+
TODO(abhinavsingh): Decouple event queue setup & teardown into its own class.
8788
"""
8889

8990
def __init__(self, flags: argparse.Namespace,
@@ -110,9 +111,10 @@ def listen(self) -> None:
110111
self.socket.bind((str(self.flags.hostname), self.flags.port))
111112
self.socket.listen(self.flags.backlog)
112113
self.socket.setblocking(False)
113-
logger.info(
114-
'Listening on %s:%d' %
115-
(self.flags.hostname, self.flags.port))
114+
# Override flags.port to match the actual port
115+
# we are listening upon. This is necessary to preserve
116+
# the server port when `--port=0` is used.
117+
self.flags.port = self.socket.getsockname()[1]
116118

117119
def start_workers(self) -> None:
118120
"""Start worker processes."""
@@ -172,7 +174,6 @@ def setup(self) -> None:
172174
logger.info('Core Event enabled')
173175
self.start_event_dispatcher()
174176
self.start_workers()
175-
176177
# Send server socket to all acceptor processes.
177178
assert self.socket is not None
178179
for index in range(self.flags.num_workers):

proxy/core/acceptor/threadless.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@
3333

3434

3535
class Threadless(multiprocessing.Process):
36-
"""Threadless provides an event loop. Use it by implementing Threadless class.
36+
"""Threadless process provides an event loop.
37+
38+
Internally, for each client connection, an instance of `work_klass`
39+
is created. Threadless will invoke necessary lifecycle of the `Work` class
40+
allowing implementations to handle accepted client connections as they wish.
41+
42+
Note that, all `Work` implementations share the same underlying event loop.
3743
3844
When --threadless option is enabled, each Acceptor process also
3945
spawns one Threadless process. And instead of spawning new thread
@@ -92,8 +98,13 @@ async def handle_events(
9298
async def wait_for_tasks(
9399
self, tasks: Dict[int, Any]) -> None:
94100
for work_id in tasks:
95-
# TODO: Resolving one handle_events here can block resolution of
96-
# other tasks
101+
# TODO: Resolving one handle_events here can block
102+
# resolution of other tasks. This can happen when handle_events
103+
# is slow.
104+
#
105+
# Instead of sequential await, a better option would be to await on
106+
# list of async handle_events. This will allow all handlers to run
107+
# concurrently without blocking each other.
97108
try:
98109
teardown = await asyncio.wait_for(tasks[work_id], DEFAULT_TIMEOUT)
99110
if teardown:
@@ -152,6 +163,7 @@ def run_once(self) -> None:
152163
# until all the logic below completes.
153164
#
154165
# Invoke Threadless.handle_events
166+
#
155167
# TODO: Only send readable / writables that client originally
156168
# registered.
157169
tasks = {}

proxy/http/handler.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
class HttpProtocolHandler(Work):
6666
"""HTTP, HTTPS, HTTP2, WebSockets protocol handler.
6767
68-
Accepts `Client` connection object and manages HttpProtocolHandlerPlugin invocations.
68+
Accepts `Client` connection and delegates to HttpProtocolHandlerPlugin.
6969
"""
7070

7171
def __init__(self, client: TcpClientConnection,
@@ -86,15 +86,28 @@ def encryption_enabled(self) -> bool:
8686
return self.flags.keyfile is not None and \
8787
self.flags.certfile is not None
8888

89+
def optionally_wrap_socket(
90+
self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]:
91+
"""Attempts to wrap accepted client connection using provided certificates.
92+
93+
Shutdown and closes client connection upon error.
94+
"""
95+
if self.encryption_enabled():
96+
assert self.flags.keyfile and self.flags.certfile
97+
# TODO(abhinavsingh): Insecure TLS versions must not be accepted by default
98+
conn = wrap_socket(conn, self.flags.keyfile, self.flags.certfile)
99+
return conn
100+
89101
def initialize(self) -> None:
90102
"""Optionally upgrades connection to HTTPS, set conn in non-blocking mode and initializes plugins."""
91103
conn = self.optionally_wrap_socket(self.client.connection)
92104
conn.setblocking(False)
105+
# Update client connection reference if connection was wrapped
93106
if self.encryption_enabled():
94107
self.client = TcpClientConnection(conn=conn, addr=self.client.addr)
95108
if b'HttpProtocolHandlerPlugin' in self.flags.plugins:
96109
for klass in self.flags.plugins[b'HttpProtocolHandlerPlugin']:
97-
instance = klass(
110+
instance: HttpProtocolHandlerPlugin = klass(
98111
self.uid,
99112
self.flags,
100113
self.client,
@@ -115,7 +128,6 @@ def get_events(self) -> Dict[socket.socket, int]:
115128
}
116129
if self.client.has_buffer():
117130
events[self.client.connection] |= selectors.EVENT_WRITE
118-
119131
# HttpProtocolHandlerPlugin.get_descriptors
120132
for plugin in self.plugins.values():
121133
plugin_read_desc, plugin_write_desc = plugin.get_descriptors()
@@ -129,7 +141,6 @@ def get_events(self) -> Dict[socket.socket, int]:
129141
events[w] = selectors.EVENT_WRITE
130142
else:
131143
events[w] |= selectors.EVENT_WRITE
132-
133144
return events
134145

135146
def handle_events(
@@ -189,17 +200,6 @@ def shutdown(self) -> None:
189200
logger.debug('Client connection closed')
190201
super().shutdown()
191202

192-
def optionally_wrap_socket(
193-
self, conn: socket.socket) -> Union[ssl.SSLSocket, socket.socket]:
194-
"""Attempts to wrap accepted client connection using provided certificates.
195-
196-
Shutdown and closes client connection upon error.
197-
"""
198-
if self.encryption_enabled():
199-
assert self.flags.keyfile and self.flags.certfile
200-
conn = wrap_socket(conn, self.flags.keyfile, self.flags.certfile)
201-
return conn
202-
203203
def connection_inactive_for(self) -> float:
204204
return time.time() - self.last_activity
205205

proxy/http/plugin.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,23 @@ def name(self) -> str:
6868
@abstractmethod
6969
def get_descriptors(
7070
self) -> Tuple[List[socket.socket], List[socket.socket]]:
71+
"""Implementations must return a list of descriptions that they wish to
72+
read from and write into."""
7173
return [], [] # pragma: no cover
7274

7375
@abstractmethod
7476
def write_to_descriptors(self, w: Writables) -> bool:
77+
"""Implementations must now write/flush data over the socket.
78+
79+
Note that buffer management is in-build into the connection classes.
80+
Hence implementations MUST call `flush` here, to send any buffered data
81+
over the socket.
82+
"""
7583
return False # pragma: no cover
7684

7785
@abstractmethod
7886
def read_from_descriptors(self, r: Readables) -> bool:
87+
"""Implementations must now read data over the socket."""
7988
return False # pragma: no cover
8089

8190
@abstractmethod
@@ -96,4 +105,7 @@ def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]:
96105

97106
@abstractmethod
98107
def on_client_connection_close(self) -> None:
108+
"""Client connection shutdown has been received, flush has been called,
109+
perform any cleanup work here.
110+
"""
99111
pass # pragma: no cover

proxy/http/proxy/plugin.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
:copyright: (c) 2013-present by Abhinav Singh and contributors.
99
:license: BSD, see LICENSE for more details.
1010
"""
11-
from abc import ABC, abstractmethod
11+
import socket
1212
import argparse
13-
from typing import Optional
13+
1414
from uuid import UUID
15-
from ..parser import HttpParser
15+
from typing import List, Optional, Tuple
16+
from abc import ABC, abstractmethod
1617

18+
from ..parser import HttpParser
1719

20+
from ...common.types import Readables, Writables
1821
from ...core.event import EventQueue
1922
from ...core.connection import TcpClientConnection
2023

@@ -42,6 +45,36 @@ def name(self) -> str:
4245
access a specific plugin by its name."""
4346
return self.__class__.__name__ # pragma: no cover
4447

48+
# TODO(abhinavsingh): get_descriptors, write_to_descriptors, read_from_descriptors
49+
# can be placed into their own abstract class which can then be shared by
50+
# HttpProxyBasePlugin, HttpWebServerBasePlugin and HttpProtocolHandlerPlugin class.
51+
#
52+
# Currently code has been shamelessly copied. Also these methods are not
53+
# marked as abstract to avoid breaking custom plugins written by users for
54+
# previous versions of proxy.py
55+
#
56+
# Since 3.4.0
57+
#
58+
# @abstractmethod
59+
def get_descriptors(
60+
self) -> Tuple[List[socket.socket], List[socket.socket]]:
61+
return [], [] # pragma: no cover
62+
63+
# @abstractmethod
64+
def write_to_descriptors(self, w: Writables) -> bool:
65+
"""Implementations must now write/flush data over the socket.
66+
67+
Note that buffer management is in-build into the connection classes.
68+
Hence implementations MUST call `flush` here, to send any buffered data
69+
over the socket.
70+
"""
71+
return False # pragma: no cover
72+
73+
# @abstractmethod
74+
def read_from_descriptors(self, r: Readables) -> bool:
75+
"""Implementations must now read data over the socket."""
76+
return False # pragma: no cover
77+
4578
@abstractmethod
4679
def before_upstream_connection(
4780
self, request: HttpParser) -> Optional[HttpParser]:

proxy/http/proxy/server.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def __init__(
122122
self.plugins: Dict[str, HttpProxyBasePlugin] = {}
123123
if b'HttpProxyBasePlugin' in self.flags.plugins:
124124
for klass in self.flags.plugins[b'HttpProxyBasePlugin']:
125-
instance = klass(
125+
instance: HttpProxyBasePlugin = klass(
126126
self.uid,
127127
self.flags,
128128
self.client,
@@ -147,10 +147,25 @@ def get_descriptors(
147147
if self.server and not self.server.closed and \
148148
self.server.has_buffer() and self.server.connection:
149149
w.append(self.server.connection)
150+
151+
# TODO(abhinavsingh): We need to keep a mapping of plugin and
152+
# descriptors registered by them, so that within write/read blocks
153+
# we can invoke the right plugin callbacks.
154+
for plugin in self.plugins.values():
155+
plugin_read_desc, plugin_write_desc = plugin.get_descriptors()
156+
r.extend(plugin_read_desc)
157+
w.extend(plugin_write_desc)
158+
150159
return r, w
151160

152161
def write_to_descriptors(self, w: Writables) -> bool:
153-
if self.request.has_upstream_server() and \
162+
if self.server and self.server.connection not in w:
163+
# Currently, we just call write/read block of each plugins. It is
164+
# plugins responsibility to ignore this callback, if passed descriptors
165+
# doesn't contain the descriptor they registered.
166+
for plugin in self.plugins.values():
167+
plugin.write_to_descriptors(w)
168+
elif self.request.has_upstream_server() and \
154169
self.server and not self.server.closed and \
155170
self.server.has_buffer() and \
156171
self.server.connection in w:
@@ -172,7 +187,13 @@ def write_to_descriptors(self, w: Writables) -> bool:
172187
return False
173188

174189
def read_from_descriptors(self, r: Readables) -> bool:
175-
if self.request.has_upstream_server() \
190+
if self.server and self.server.connection not in r:
191+
# Currently, we just call write/read block of each plugins. It is
192+
# plugins responsibility to ignore this callback, if passed descriptors
193+
# doesn't contain the descriptor they registered.
194+
for plugin in self.plugins.values():
195+
plugin.write_to_descriptors(r)
196+
elif self.request.has_upstream_server() \
176197
and self.server \
177198
and not self.server.closed \
178199
and self.server.connection in r:

0 commit comments

Comments
 (0)