Skip to content

Commit 62012ed

Browse files
authored
Enable docker containers for TLS Interception (#375)
* Move static values to constants.py * Install openssl in docker containers for TLS interception * Complete TLS Interception with Docker doc
1 parent 16af678 commit 62012ed

File tree

4 files changed

+150
-49
lines changed

4 files changed

+150
-49
lines changed

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \
2020

2121
COPY --from=builder /deps /usr/local
2222

23+
# Install openssl to enable TLS interception within container
24+
RUN apk update && apk add openssl
25+
2326
EXPOSE 8899/tcp
2427
ENTRYPOINT [ "proxy" ]
2528
CMD [ "--hostname=0.0.0.0" ]

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Table of Contents
6363
* [Plugin Ordering](#plugin-ordering)
6464
* [End-to-End Encryption](#end-to-end-encryption)
6565
* [TLS Interception](#tls-interception)
66+
* [TLS Interception With Docker](#tls-interception-with-docker)
6667
* [Proxy Over SSH Tunnel](#proxy-over-ssh-tunnel)
6768
* [Proxy Remote Requests Locally](#proxy-remote-requests-locally)
6869
* [Proxy Local Requests Remotely](#proxy-local-requests-remotely)
@@ -862,9 +863,94 @@ cached file instead of plain text.
862863
Now use CA flags with other
863864
[plugin examples](#plugin-examples) to see them work with `https` traffic.
864865
866+
## TLS Interception With Docker
867+
868+
Important notes about TLS Interception with Docker container:
869+
870+
- Since `v2.2.0`, `proxy.py` docker container also ships with `openssl`. This allows `proxy.py`
871+
to generate certificates on the fly for TLS Interception.
872+
873+
- For security reasons, `proxy.py` docker container doesn't ship with CA certificates.
874+
875+
Here is how to start a `proxy.py` docker container
876+
with TLS Interception:
877+
878+
1. Generate CA certificates on host computer
879+
880+
```bash
881+
❯ make ca-certificates
882+
```
883+
884+
2. Copy all generated certificates into a separate directory. We'll later mount this directory into our docker container
885+
886+
```bash
887+
❯ mkdir /tmp/ca-certificates
888+
❯ cp ca-cert.pem ca-key.pem ca-signing-key.pem /tmp/ca-certificates
889+
```
890+
891+
3. Start docker container
892+
893+
```bash
894+
❯ docker run -it --rm \
895+
-v /tmp/ca-certificates:/tmp/ca-certificates \
896+
-p 8899:8899 \
897+
abhinavsingh/proxy.py:latest \
898+
--hostname 0.0.0.0 \
899+
--plugins proxy.plugin.CacheResponsesPlugin \
900+
--ca-key-file /tmp/ca-certificates/ca-key.pem \
901+
--ca-cert-file /tmp/ca-certificates/ca-cert.pem \
902+
--ca-signing-key /tmp/ca-certificates/ca-signing-key.pem
903+
```
904+
905+
- `-v /tmp/ca-certificates:/tmp/ca-certificates` flag mounts our CA certificate directory in container environment
906+
- `--plugins proxy.plugin.CacheResponsesPlugin` enables `CacheResponsesPlugin` so that we can inspect intercepted traffic
907+
- `--ca-*` flags enable TLS Interception.
908+
909+
4. From another terminal, try TLS Interception using `curl`. You can omit `--cacert` flag if CA certificate is already trusted by the system.
910+
911+
```bash
912+
❯ curl -v \
913+
--cacert ca-cert.pem \
914+
-x 127.0.0.1:8899 \
915+
https://httpbin.org/get
916+
```
917+
918+
5. Verify `issuer` field from response headers.
919+
920+
```bash
921+
* Server certificate:
922+
* subject: CN=httpbin.org; C=NA; ST=Unavailable; L=Unavailable; O=Unavailable; OU=Unavailable
923+
* start date: Jun 17 09:26:57 2020 GMT
924+
* expire date: Jun 17 09:26:57 2022 GMT
925+
* subjectAltName: host "httpbin.org" matched cert's "httpbin.org"
926+
* issuer: CN=example.com
927+
* SSL certificate verify ok.
928+
```
929+
930+
6. Back on docker terminal, copy response dump path logs.
931+
932+
```bash
933+
...[redacted]... [I] access_log:338 - 172.17.0.1:56498 - CONNECT httpbin.org:443 - 1031 bytes - 1216.70 ms
934+
...[redacted]... [I] close:49 - Cached response at /tmp/httpbin.org-ae1a927d064e4ab386ea319eb38fe251.txt
935+
```
936+
937+
7. In another terminal, `cat` the response dump:
938+
939+
```bash
940+
❯ docker exec -it $(docker ps | grep proxy.py | awk '{ print $1 }') cat /tmp/httpbin.org-ae1a927d064e4ab386ea319eb38fe251.txt
941+
HTTP/1.1 200 OK
942+
...[redacted]...
943+
{
944+
...[redacted]...,
945+
"url": "http://httpbin.org/get"
946+
}
947+
```
948+
865949
Proxy Over SSH Tunnel
866950
=====================
867951
952+
**This is a WIP and may not work as documented**
953+
868954
Requires `paramiko` to work. See [requirements-tunnel.txt](https://github.com/abhinavsingh/proxy.py/blob/develop/requirements-tunnel.txt)
869955
870956
## Proxy Remote Requests Locally

proxy/common/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111
import os
1212
import time
13+
import pathlib
1314
import ipaddress
1415

1516
from typing import List
@@ -75,3 +76,13 @@
7576
DEFAULT_VERSION = False
7677
DEFAULT_HTTP_PORT = 80
7778
DEFAULT_MAX_SEND_SIZE = 16 * 1024
79+
80+
DEFAULT_DATA_DIRECTORY_PATH = os.path.join(str(pathlib.Path.home()), '.proxy')
81+
82+
# Cor plugins enabled by default or via flags
83+
PLUGIN_HTTP_PROXY = 'proxy.http.proxy.HttpProxyPlugin'
84+
PLUGIN_WEB_SERVER = 'proxy.http.server.HttpWebServerPlugin'
85+
PLUGIN_PAC_FILE = 'proxy.http.server.HttpWebServerPacFilePlugin'
86+
PLUGIN_DEVTOOLS_PROTOCOL = 'proxy.http.inspector.DevtoolsProtocolPlugin'
87+
PLUGIN_DASHBOARD = 'proxy.dashboard.dashboard.ProxyDashboard'
88+
PLUGIN_INSPECT_TRAFFIC = 'proxy.dashboard.inspect_traffic.InspectTrafficPlugin'

proxy/common/flags.py

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import multiprocessing
2121
import sys
2222
import inspect
23-
import pathlib
2423

2524
from typing import Optional, Union, Dict, List, TypeVar, Type, cast, Any, Tuple
2625

@@ -33,7 +32,9 @@
3332
from .constants import DEFAULT_PAC_FILE_URL_PATH, DEFAULT_PAC_FILE, DEFAULT_PLUGINS, DEFAULT_PID_FILE, DEFAULT_PORT
3433
from .constants import DEFAULT_NUM_WORKERS, DEFAULT_VERSION, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_IPV6_HOSTNAME
3534
from .constants import DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CLIENT_RECVBUF_SIZE, DEFAULT_STATIC_SERVER_DIR
36-
from .constants import DEFAULT_ENABLE_DASHBOARD, COMMA, DOT
35+
from .constants import DEFAULT_ENABLE_DASHBOARD, DEFAULT_DATA_DIRECTORY_PATH, COMMA, DOT
36+
from .constants import PLUGIN_HTTP_PROXY, PLUGIN_WEB_SERVER, PLUGIN_PAC_FILE
37+
from .constants import PLUGIN_DEVTOOLS_PROTOCOL, PLUGIN_DASHBOARD, PLUGIN_INSPECT_TRAFFIC
3738
from .version import __version__
3839

3940
__homepage__ = 'https://github.com/abhinavsingh/proxy.py'
@@ -49,9 +50,6 @@
4950
class Flags:
5051
"""Contains all input flags and inferred input parameters."""
5152

52-
ROOT_DATA_DIR_NAME = '.proxy.py'
53-
GENERATED_CERTS_DIR_NAME = 'certificates'
54-
5553
def __init__(
5654
self,
5755
auth_code: Optional[bytes] = DEFAULT_BASIC_AUTH,
@@ -112,16 +110,25 @@ def __init__(
112110
self.devtools_ws_path: bytes = devtools_ws_path
113111
self.enable_events: bool = enable_events
114112

115-
self.proxy_py_data_dir = os.path.join(
116-
str(pathlib.Path.home()), self.ROOT_DATA_DIR_NAME)
113+
self.proxy_py_data_dir = DEFAULT_DATA_DIRECTORY_PATH
117114
os.makedirs(self.proxy_py_data_dir, exist_ok=True)
118115

119116
self.ca_cert_dir: Optional[str] = ca_cert_dir
120117
if self.ca_cert_dir is None:
121118
self.ca_cert_dir = os.path.join(
122-
self.proxy_py_data_dir, self.GENERATED_CERTS_DIR_NAME)
119+
self.proxy_py_data_dir, 'certificates')
123120
os.makedirs(self.ca_cert_dir, exist_ok=True)
124121

122+
def tls_interception_enabled(self) -> bool:
123+
return self.ca_key_file is not None and \
124+
self.ca_cert_dir is not None and \
125+
self.ca_signing_key_file is not None and \
126+
self.ca_cert_file is not None
127+
128+
def encryption_enabled(self) -> bool:
129+
return self.keyfile is not None and \
130+
self.certfile is not None
131+
125132
@classmethod
126133
def initialize(
127134
cls: Type[T],
@@ -138,51 +145,59 @@ def initialize(
138145
'A future version of pip will drop support for Python 2.7.')
139146
sys.exit(1)
140147

141-
args = Flags.init_parser().parse_args(input_args)
148+
parser = Flags.init_parser()
149+
args = parser.parse_args(input_args)
142150

151+
# Print version and exit
143152
if args.version:
144153
print(__version__)
145154
sys.exit(0)
146155

147-
if (args.cert_file and args.key_file) and \
148-
(args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file):
149-
print('You can either enable end-to-end encryption OR TLS interception,'
150-
'not both together.')
151-
sys.exit(1)
152-
153-
auth_code = None
154-
if args.basic_auth:
155-
auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth))
156-
156+
# Setup logging module
157157
Flags.setup_logger(args.log_file, args.log_level, args.log_format)
158-
Flags.set_open_file_limit(args.open_file_limit)
159158

160-
http_proxy_plugin = 'proxy.http.proxy.HttpProxyPlugin'
161-
web_server_plugin = 'proxy.http.server.HttpWebServerPlugin'
162-
pac_file_plugin = 'proxy.http.server.HttpWebServerPacFilePlugin'
163-
devtools_protocol_plugin = 'proxy.http.inspector.DevtoolsProtocolPlugin'
164-
dashboard_plugin = 'proxy.dashboard.dashboard.ProxyDashboard'
165-
inspect_traffic_plugin = 'proxy.dashboard.inspect_traffic.InspectTrafficPlugin'
159+
# Setup limits
160+
Flags.set_open_file_limit(args.open_file_limit)
166161

167162
default_plugins: List[Tuple[str, bool]] = []
168163
if args.enable_dashboard:
169-
default_plugins.append((web_server_plugin, True))
164+
default_plugins.append((PLUGIN_WEB_SERVER, True))
170165
args.enable_static_server = True
171-
default_plugins.append((dashboard_plugin, True))
172-
default_plugins.append((inspect_traffic_plugin, True))
166+
default_plugins.append((PLUGIN_DASHBOARD, True))
167+
default_plugins.append((PLUGIN_INSPECT_TRAFFIC, True))
173168
args.enable_events = True
174169
args.enable_devtools = True
175170
if args.enable_devtools:
176-
default_plugins.append((devtools_protocol_plugin, True))
177-
default_plugins.append((web_server_plugin, True))
171+
default_plugins.append((PLUGIN_DEVTOOLS_PROTOCOL, True))
172+
default_plugins.append((PLUGIN_WEB_SERVER, True))
178173
if not args.disable_http_proxy:
179-
default_plugins.append((http_proxy_plugin, True))
174+
default_plugins.append((PLUGIN_HTTP_PROXY, True))
180175
if args.enable_web_server or \
181176
args.pac_file is not None or \
182177
args.enable_static_server:
183-
default_plugins.append((web_server_plugin, True))
178+
default_plugins.append((PLUGIN_WEB_SERVER, True))
184179
if args.pac_file is not None:
185-
default_plugins.append((pac_file_plugin, True))
180+
default_plugins.append((PLUGIN_PAC_FILE, True))
181+
182+
plugins = Flags.load_plugins(
183+
bytes_(
184+
'%s,%s' %
185+
(text_(COMMA).join(collections.OrderedDict(default_plugins).keys()),
186+
opts.get('plugins', args.plugins))))
187+
188+
# proxy.py currently cannot serve over HTTPS and perform TLS interception
189+
# at the same time. Check if user is trying to enable both feature
190+
# at the same time.
191+
if (args.cert_file and args.key_file) and \
192+
(args.ca_key_file and args.ca_cert_file and args.ca_signing_key_file):
193+
print('You can either enable end-to-end encryption OR TLS interception,'
194+
'not both together.')
195+
sys.exit(1)
196+
197+
# Generate auth_code required for basic authentication if enabled
198+
auth_code = None
199+
if args.basic_auth:
200+
auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth))
186201

187202
return cls(
188203
auth_code=cast(Optional[bytes], opts.get('auth_code', auth_code)),
@@ -258,23 +273,9 @@ def initialize(
258273
opts.get(
259274
'enable_events',
260275
args.enable_events)),
261-
plugins=Flags.load_plugins(
262-
bytes_(
263-
'%s,%s' %
264-
(text_(COMMA).join(collections.OrderedDict(default_plugins).keys()),
265-
opts.get('plugins', args.plugins)))),
276+
plugins=plugins,
266277
pid_file=cast(Optional[str], opts.get('pid_file', args.pid_file)))
267278

268-
def tls_interception_enabled(self) -> bool:
269-
return self.ca_key_file is not None and \
270-
self.ca_cert_dir is not None and \
271-
self.ca_signing_key_file is not None and \
272-
self.ca_cert_file is not None
273-
274-
def encryption_enabled(self) -> bool:
275-
return self.keyfile is not None and \
276-
self.certfile is not None
277-
278279
@staticmethod
279280
def init_parser() -> argparse.ArgumentParser:
280281
"""Initializes and returns argument parser."""

0 commit comments

Comments
 (0)