Skip to content

Commit 74b68ea

Browse files
committed
Add CustomDnsResolver plugin. Addresses #535 and #664
1 parent 2a9db3a commit 74b68ea

File tree

11 files changed

+114
-7
lines changed

11 files changed

+114
-7
lines changed

LICENSE

+1-1
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

+17
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
- [Proxy Pool Plugin](#proxypoolplugin)
5757
- [FilterByClientIpPlugin](#filterbyclientipplugin)
5858
- [ModifyChunkResponsePlugin](#modifychunkresponseplugin)
59+
- [CustomDnsResolverPlugin](#customdnsresolverplugin)
5960
- [HTTP Web Server Plugins](#http-web-server-plugins)
6061
- [Reverse Proxy](#reverse-proxy)
6162
- [Web Server Route](#web-server-route)
@@ -720,6 +721,22 @@ plugin
720721
721722
Modify `ModifyChunkResponsePlugin` to your taste. Example, instead of sending hardcoded chunks, parse and modify the original `JSON` chunks received from the upstream server.
722723
724+
### CustomDnsResolverPlugin
725+
726+
This plugin demonstrate how to use a custom DNS resolution implementation with `proxy.py`.
727+
This example plugin currently uses Python's in-built resolution mechanism. Customize code
728+
to your taste. Example, query your custom DNS server, implement DoH or other mechanisms.
729+
730+
Start `proxy.py` as:
731+
732+
```bash
733+
❯ proxy \
734+
--plugins proxy.plugin.CustomDnsResolverPlugin
735+
```
736+
737+
`HttpProxyBasePlugin.resolve_dns` can also be used to configure `network interface` which
738+
must be used as the `source_address` for connection to the upstream server.
739+
723740
## HTTP Web Server Plugins
724741
725742
### Reverse Proxy

proxy/common/utils.py

+2-2
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

+4-2
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

+16
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

+15-1
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,21 @@ 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' %

proxy/plugin/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
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
2425

2526
__all__ = [
2627
'CacheResponsesPlugin',
@@ -37,4 +38,5 @@
3738
'FilterByClientIpPlugin',
3839
'ModifyChunkResponsePlugin',
3940
'FilterByURLRegexPlugin',
41+
'CustomDnsResolverPlugin',
4042
]

proxy/plugin/custom_dns_resolver.py

+52
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

+3-1
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

+1
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

+1
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)