Skip to content

Commit bf4ee90

Browse files
authored
CustomDnsResolver plugin, CloudflareDnsResolver plugin, Allow plugins to configure network interface (#671)
* Add CustomDnsResolver plugin. Addresses #535 and #664 * Add cloudflare DNS resolver plugin * Lint fixes
1 parent 752146a commit bf4ee90

File tree

12 files changed

+244
-15
lines changed

12 files changed

+244
-15
lines changed

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Copyright (c) 2013-2022 by Abhinav Singh and contributors.
1+
Copyright (c) 2013-present by Abhinav Singh and contributors.
22
All rights reserved.
33

44
Redistribution and use in source and binary forms, with or without modification,

README.md

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
- [Proxy Pool Plugin](#proxypoolplugin)
5757
- [FilterByClientIpPlugin](#filterbyclientipplugin)
5858
- [ModifyChunkResponsePlugin](#modifychunkresponseplugin)
59+
- [CloudflareDnsResolverPlugin](#cloudflarednsresolverplugin)
60+
- [CustomDnsResolverPlugin](#customdnsresolverplugin)
5961
- [HTTP Web Server Plugins](#http-web-server-plugins)
6062
- [Reverse Proxy](#reverse-proxy)
6163
- [Web Server Route](#web-server-route)
@@ -720,6 +722,36 @@ plugin
720722
721723
Modify `ModifyChunkResponsePlugin` to your taste. Example, instead of sending hardcoded chunks, parse and modify the original `JSON` chunks received from the upstream server.
722724
725+
### CloudflareDnsResolverPlugin
726+
727+
This plugin uses `Cloudflare` hosted `DNS-over-HTTPS` [API](https://developers.cloudflare.com/1.1.1.1/encrypted-dns/dns-over-https/make-api-requests/dns-json) (json).
728+
729+
Start `proxy.py` as:
730+
731+
```bash
732+
❯ proxy \
733+
--plugins proxy.plugin.CloudflareDnsResolverPlugin
734+
```
735+
736+
By default, `CloudflareDnsResolverPlugin` runs in `security` mode (provides malware protection). Use `--cloudflare-dns-mode family` to also enable
737+
adult content protection.
738+
739+
### CustomDnsResolverPlugin
740+
741+
This plugin demonstrate how to use a custom DNS resolution implementation with `proxy.py`.
742+
This example plugin currently uses Python's in-built resolution mechanism. Customize code
743+
to your taste. Example, query your custom DNS server, implement DoH or other mechanisms.
744+
745+
Start `proxy.py` as:
746+
747+
```bash
748+
❯ proxy \
749+
--plugins proxy.plugin.CustomDnsResolverPlugin
750+
```
751+
752+
`HttpProxyBasePlugin.resolve_dns` can also be used to configure `network interface` which
753+
must be used as the `source_address` for connection to the upstream server.
754+
723755
## HTTP Web Server Plugins
724756
725757
### Reverse Proxy
@@ -1013,7 +1045,9 @@ with TLS Interception:
10131045

10141046
**This is a WIP and may not work as documented**
10151047

1016-
Requires `paramiko` to work. See [requirements-tunnel.txt](https://github.com/abhinavsingh/proxy.py/blob/develop/requirements-tunnel.txt)
1048+
Requires `paramiko` to work.
1049+
1050+
See [requirements-tunnel.txt](https://github.com/abhinavsingh/proxy.py/blob/develop/requirements-tunnel.txt)
10171051

10181052
## Proxy Remote Requests Locally
10191053

@@ -1182,7 +1216,8 @@ if __name__ == '__main__':
11821216
11831217
## Loading Plugins
11841218
1185-
You can, of course, list plugins to load in the input arguments list of `proxy.main` or `Proxy` constructor. Use the `--plugins` flag when starting from command line:
1219+
You can, of course, list plugins to load in the input arguments list of `proxy.main` or
1220+
`Proxy` constructor. Use the `--plugins` flag when starting from command line:
11861221
11871222
```python
11881223
import proxy
@@ -1193,7 +1228,8 @@ if __name__ == '__main__':
11931228
])
11941229
```
11951230
1196-
For simplicity you can pass the list of plugins to load as a keyword argument to `proxy.main` or the `Proxy` constructor:
1231+
For simplicity you can pass the list of plugins to load as a keyword argument to `proxy.main` or
1232+
the `Proxy` constructor:
11971233
11981234
```python
11991235
import proxy
@@ -1353,8 +1389,8 @@ Contributors must start `proxy.py` from source to verify and develop new feature
13531389
13541390
See [Run proxy.py from command line using repo source](#from-command-line-using-repo-source) for details.
13551391
1356-
[![WARNING](https://img.shields.io/static/v1?label=MacOS&message=warning&color=red)]
1357-
(https://github.com/abhinavsingh/proxy.py/issues/642#issuecomment-960819271) On `macOS`
1392+
1393+
[![WARNING](https://img.shields.io/static/v1?label=MacOS&message=warning&color=red)](https://github.com/abhinavsingh/proxy.py/issues/642#issuecomment-960819271) On `macOS`
13581394
you must install `Python` using `pyenv`, as `Python` installed via `homebrew` tends
13591395
to be problematic. See linked thread for more details.
13601396
@@ -1575,7 +1611,8 @@ FILE
15751611
15761612
# Run Dashboard
15771613
1578-
Dashboard is currently under development and not yet bundled with `pip` packages. To run dashboard, you must checkout the source.
1614+
Dashboard is currently under development and not yet bundled with `pip` packages.
1615+
To run dashboard, you must checkout the source.
15791616
15801617
Dashboard is written in Typescript and SCSS, so let's build it first using:
15811618
@@ -1606,9 +1643,12 @@ $ open http://localhost:8899/dashboard/
16061643
16071644
## Inspect Traffic
16081645
1609-
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.
1646+
Wait for embedded `Chrome Dev Console` to load. Currently, detail about all traffic flowing
1647+
through `proxy.py` is pushed to the `Inspect Traffic` tab. However, received payloads are not
1648+
yet integrated with the embedded dev console.
16101649
1611-
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.
1650+
Current functionality can be verified by opening the `Dev Console` of dashboard and inspecting
1651+
the websocket connection that dashboard established with the `proxy.py` server.
16121652
16131653
[![Proxy.Py Dashboard Inspect Traffic](https://github.com/raw/abhinavsingh/proxy.py/develop/Dashboard.png)](https://github.com/abhinavsingh/proxy.py)
16141654

proxy/common/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def wrap_socket(
181181

182182

183183
def new_socket_connection(
184-
addr: Tuple[str, int], timeout: int = DEFAULT_TIMEOUT,
184+
addr: Tuple[str, int], timeout: int = DEFAULT_TIMEOUT, source_address: Optional[Tuple[str, int]] = None,
185185
) -> socket.socket:
186186
conn = None
187187
try:
@@ -205,7 +205,7 @@ def new_socket_connection(
205205
return conn
206206

207207
# try to establish dual stack IPv4/IPv6 connection.
208-
return socket.create_connection(addr, timeout=timeout)
208+
return socket.create_connection(addr, timeout=timeout, source_address=source_address)
209209

210210

211211
class socket_connection(contextlib.ContextDecorator):

proxy/core/connection/server.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ def connection(self) -> Union[ssl.SSLSocket, socket.socket]:
3333
raise TcpConnectionUninitializedException()
3434
return self._conn
3535

36-
def connect(self) -> None:
36+
def connect(self, addr: Optional[Tuple[str, int]] = None, source_address: Optional[Tuple[str, int]] = None) -> None:
3737
if self._conn is None:
38-
self._conn = new_socket_connection(self.addr)
38+
self._conn = new_socket_connection(
39+
addr or self.addr, source_address=source_address,
40+
)
3941
self.closed = False
4042

4143
def wrap(self, hostname: str, ca_file: Optional[str]) -> None:

proxy/http/proxy/plugin.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ def read_from_descriptors(self, r: Readables) -> bool:
7777
"""Implementations must now read data over the socket."""
7878
return False # pragma: no cover
7979

80+
def resolve_dns(self, host: str, port: int) -> Tuple[Optional[str], Optional[Tuple[str, int]]]:
81+
"""Resolve upstream server host to an IP address.
82+
83+
Optionally also override the source address to use for
84+
connection with upstream server.
85+
86+
For upstream IP:
87+
Return None to use default resolver available to the system.
88+
Return ip address as string to use your custom resolver.
89+
90+
For source address:
91+
Return None to use default source address
92+
Return 2-tuple representing (host, port) to use as source address
93+
"""
94+
return None, None
95+
8096
@abstractmethod
8197
def before_upstream_connection(
8298
self, request: HttpParser,

proxy/http/proxy/server.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,13 +509,30 @@ def connect_upstream(self) -> None:
509509
'Connecting to upstream %s:%s' %
510510
(text_(host), port),
511511
)
512-
self.server.connect()
512+
# Invoke plugin.resolve_dns
513+
upstream_ip, source_addr = None, None
514+
for plugin in self.plugins.values():
515+
upstream_ip, source_addr = plugin.resolve_dns(
516+
text_(host), port,
517+
)
518+
if upstream_ip or source_addr:
519+
break
520+
# Connect with overridden upstream IP and source address
521+
# if any of the plugin returned a non-null value.
522+
self.server.connect(
523+
addr=None if not upstream_ip else (
524+
upstream_ip, port,
525+
), source_address=source_addr,
526+
)
513527
self.server.connection.setblocking(False)
514528
logger.debug(
515529
'Connected to upstream %s:%s' %
516530
(text_(host), port),
517531
)
518532
except Exception as e: # TimeoutError, socket.gaierror
533+
logger.exception(
534+
'Unable to connect with upstream server', exc_info=e,
535+
)
519536
self.server.closed = True
520537
raise ProxyConnectionFailed(text_(host), port, repr(e)) from e
521538
else:

proxy/plugin/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from .filter_by_client_ip import FilterByClientIpPlugin
2222
from .filter_by_url_regex import FilterByURLRegexPlugin
2323
from .modify_chunk_response import ModifyChunkResponsePlugin
24+
from .custom_dns_resolver import CustomDnsResolverPlugin
25+
from .cloudflare_dns import CloudflareDnsResolverPlugin
2426

2527
__all__ = [
2628
'CacheResponsesPlugin',
@@ -37,4 +39,6 @@
3739
'FilterByClientIpPlugin',
3840
'ModifyChunkResponsePlugin',
3941
'FilterByURLRegexPlugin',
42+
'CustomDnsResolverPlugin',
43+
'CloudflareDnsResolverPlugin',
4044
]

proxy/plugin/cloudflare_dns.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
import logging
12+
13+
try:
14+
import httpx
15+
except ImportError:
16+
pass
17+
18+
from typing import Optional, Tuple
19+
20+
from ..common.flag import flags
21+
from ..http.parser import HttpParser
22+
from ..http.proxy import HttpProxyBasePlugin
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
flags.add_argument(
28+
'--cloudflare-dns-mode',
29+
type=str,
30+
default='security',
31+
help='Default: security. Either "security" (for malware protection) ' +
32+
'or "family" (for malware and adult content protection)',
33+
)
34+
35+
36+
class CloudflareDnsResolverPlugin(HttpProxyBasePlugin):
37+
"""This plugin uses Cloudflare DNS resolver to provide protection
38+
against malwares and adult content. Implementation uses DoH specification.
39+
40+
See https://developers.cloudflare.com/1.1.1.1/1.1.1.1-for-families
41+
See https://developers.cloudflare.com/1.1.1.1/encrypted-dns/dns-over-https/make-api-requests/dns-json
42+
43+
NOTE: For this plugin to work, make sure to bypass proxy for 1.1.1.1
44+
45+
NOTE: This plugin requires additional dependency because DoH mandates
46+
a HTTP2 complaint client. Install `httpx` dependency as:
47+
48+
pip install "httpx[http2]"
49+
"""
50+
51+
def resolve_dns(self, host: str, port: int) -> Tuple[Optional[str], Optional[Tuple[str, int]]]:
52+
try:
53+
context = httpx.create_ssl_context(http2=True)
54+
# TODO: Support resolution via Authority (SOA) to add support for
55+
# AAAA (IPv6) query
56+
r = httpx.get(
57+
'https://{0}.cloudflare-dns.com/dns-query?name={1}&type=A'.format(
58+
self.flags.cloudflare_dns_mode, host,
59+
),
60+
headers={'accept': 'application/dns-json'},
61+
verify=context,
62+
timeout=httpx.Timeout(timeout=5.0),
63+
proxies={
64+
'all://': None,
65+
},
66+
)
67+
if r.status_code != 200:
68+
return None, None
69+
response = r.json()
70+
answers = response.get('Answer', [])
71+
if len(answers) == 0:
72+
return None, None
73+
# TODO: Utilize TTL to cache response locally
74+
# instead of making a DNS query repeatedly for the same host.
75+
return answers[0]['data'], None
76+
except Exception as e:
77+
logger.exception('Unable to resolve DNS-over-HTTPS', exc_info=e)
78+
return None, None
79+
80+
def before_upstream_connection(
81+
self, request: HttpParser,
82+
) -> Optional[HttpParser]:
83+
return request
84+
85+
def handle_client_request(
86+
self, request: HttpParser,
87+
) -> Optional[HttpParser]:
88+
return request
89+
90+
def handle_upstream_chunk(self, chunk: memoryview) -> memoryview:
91+
return chunk
92+
93+
def on_upstream_connection_close(self) -> None:
94+
pass

proxy/plugin/custom_dns_resolver.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
import socket
12+
13+
from typing import Optional, Tuple
14+
15+
from ..http.parser import HttpParser
16+
from ..http.proxy import HttpProxyBasePlugin
17+
18+
19+
class CustomDnsResolverPlugin(HttpProxyBasePlugin):
20+
"""This plugin demonstrate how to use your own custom DNS resolver."""
21+
22+
def resolve_dns(self, host: str, port: int) -> Tuple[Optional[str], Optional[Tuple[str, int]]]:
23+
"""Here we are using in-built python resolver for demonstration.
24+
25+
Ideally you would like to query your custom DNS server or even use DoH to make
26+
real sense out of this plugin.
27+
28+
2nd parameter returned is None. Return a 2-tuple to configure underlying interface
29+
to use for connection to the upstream server.
30+
"""
31+
try:
32+
return socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)[0][4][0], None
33+
except socket.gaierror:
34+
# Ideally we can also thrown HttpRequestRejected or HttpProtocolException here
35+
# Returning None simply fallback to core generated exceptions.
36+
return None, None
37+
38+
def before_upstream_connection(
39+
self, request: HttpParser,
40+
) -> Optional[HttpParser]:
41+
return request
42+
43+
def handle_client_request(
44+
self, request: HttpParser,
45+
) -> Optional[HttpParser]:
46+
return request
47+
48+
def handle_upstream_chunk(self, chunk: memoryview) -> memoryview:
49+
return chunk
50+
51+
def on_upstream_connection_close(self) -> None:
52+
pass

tests/common/test_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ def test_new_socket_connection_ipv6(self, mock_socket: mock.Mock) -> None:
4343
@mock.patch('socket.create_connection')
4444
def test_new_socket_connection_dual(self, mock_socket: mock.Mock) -> None:
4545
conn = new_socket_connection(self.addr_dual)
46-
mock_socket.assert_called_with(self.addr_dual, timeout=DEFAULT_TIMEOUT)
46+
mock_socket.assert_called_with(
47+
self.addr_dual, timeout=DEFAULT_TIMEOUT, source_address=None,
48+
)
4749
self.assertEqual(conn, mock_socket.return_value)
4850

4951
@mock.patch('proxy.common.utils.new_socket_connection')

tests/http/test_http_proxy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def test_proxy_plugin_on_and_before_upstream_connection(
6060
self.plugin.return_value.read_from_descriptors.return_value = False
6161
self.plugin.return_value.before_upstream_connection.side_effect = lambda r: r
6262
self.plugin.return_value.handle_client_request.side_effect = lambda r: r
63+
self.plugin.return_value.resolve_dns.return_value = None, None
6364

6465
self._conn.recv.return_value = build_http_request(
6566
b'GET', b'http://upstream.host/not-found.html',

tests/http/test_http_proxy_tls_interception.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def mock_connection() -> Any:
133133
self.proxy_plugin.return_value.read_from_descriptors.return_value = False
134134
self.proxy_plugin.return_value.before_upstream_connection.side_effect = lambda r: r
135135
self.proxy_plugin.return_value.handle_client_request.side_effect = lambda r: r
136+
self.proxy_plugin.return_value.resolve_dns.return_value = None, None
136137

137138
self.mock_selector.return_value.select.side_effect = [
138139
[(

0 commit comments

Comments
 (0)