├── .coveragerc
├── .gitignore
├── .travis.yml
├── LICENSE
├── NOTICE
├── README.rst
├── appveyor.yml
├── setup.py
├── test
├── __init__.py
├── fixture_app.py
├── test_wsgiprox.py
└── uwsgi.ini
└── wsgiprox
├── __init__.py
├── gevent_ssl.py
├── resolvers.py
└── wsgiprox.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | concurrency = gevent
3 | branch = True
4 | omit =
5 | */test/*
6 |
7 | [report]
8 | exclude_lines =
9 | pragma: no cover
10 | if __name__ == .__main__.:
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 | local_settings.py
55 |
56 | # Flask stuff:
57 | instance/
58 | .webassets-cache
59 |
60 | # Scrapy stuff:
61 | .scrapy
62 |
63 | # Sphinx documentation
64 | docs/_build/
65 |
66 | # PyBuilder
67 | target/
68 |
69 | # IPython Notebook
70 | .ipynb_checkpoints
71 |
72 | # pyenv
73 | .python-version
74 |
75 | # celery beat schedule file
76 | celerybeat-schedule
77 |
78 | # dotenv
79 | .env
80 |
81 | # virtualenv
82 | venv/
83 | ENV/
84 |
85 | # Spyder project settings
86 | .spyderproject
87 |
88 | # Rope project settings
89 | .ropeproject
90 |
91 | ca/
92 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 |
3 | python:
4 | - "2.7"
5 | - "3.4"
6 | - "3.5"
7 | - "3.6"
8 |
9 | matrix:
10 | include:
11 | - python: "3.7"
12 | dist: xenial
13 | sudo: required
14 |
15 | os:
16 | - linux
17 |
18 | sudo: false
19 |
20 | install:
21 | - "pip install -U pip"
22 | - "pip install -U setuptools"
23 | - "pip install gevent-websocket pyopenssl"
24 | - "pip install uwsgi"
25 | - "pip install coverage pytest-cov coveralls"
26 | - python setup.py install
27 |
28 | script:
29 | - python setup.py test
30 |
31 | after_success:
32 | - coveralls
33 |
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Version 2.0, January 2004
2 | http://www.apache.org/licenses/
3 |
4 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
5 |
6 | 1. Definitions.
7 |
8 | "License" shall mean the terms and conditions for use, reproduction,
9 | and distribution as defined by Sections 1 through 9 of this document.
10 |
11 | "Licensor" shall mean the copyright owner or entity authorized by
12 | the copyright owner that is granting the License.
13 |
14 | "Legal Entity" shall mean the union of the acting entity and all
15 | other entities that control, are controlled by, or are under common
16 | control with that entity. For the purposes of this definition,
17 | "control" means (i) the power, direct or indirect, to cause the
18 | direction or management of such entity, whether by contract or
19 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
20 | outstanding shares, or (iii) beneficial ownership of such entity.
21 |
22 | "You" (or "Your") shall mean an individual or Legal Entity
23 | exercising permissions granted by this License.
24 |
25 | "Source" form shall mean the preferred form for making modifications,
26 | including but not limited to software source code, documentation
27 | source, and configuration files.
28 |
29 | "Object" form shall mean any form resulting from mechanical
30 | transformation or translation of a Source form, including but
31 | not limited to compiled object code, generated documentation,
32 | and conversions to other media types.
33 |
34 | "Work" shall mean the work of authorship, whether in Source or
35 | Object form, made available under the License, as indicated by a
36 | copyright notice that is included in or attached to the work
37 | (an example is provided in the Appendix below).
38 |
39 | "Derivative Works" shall mean any work, whether in Source or Object
40 | form, that is based on (or derived from) the Work and for which the
41 | editorial revisions, annotations, elaborations, or other modifications
42 | represent, as a whole, an original work of authorship. For the purposes
43 | of this License, Derivative Works shall not include works that remain
44 | separable from, or merely link (or bind by name) to the interfaces of,
45 | the Work and Derivative Works thereof.
46 |
47 | "Contribution" shall mean any work of authorship, including
48 | the original version of the Work and any modifications or additions
49 | to that Work or Derivative Works thereof, that is intentionally
50 | submitted to Licensor for inclusion in the Work by the copyright owner
51 | or by an individual or Legal Entity authorized to submit on behalf of
52 | the copyright owner. For the purposes of this definition, "submitted"
53 | means any form of electronic, verbal, or written communication sent
54 | to the Licensor or its representatives, including but not limited to
55 | communication on electronic mailing lists, source code control systems,
56 | and issue tracking systems that are managed by, or on behalf of, the
57 | Licensor for the purpose of discussing and improving the Work, but
58 | excluding communication that is conspicuously marked or otherwise
59 | designated in writing by the copyright owner as "Not a Contribution."
60 |
61 | "Contributor" shall mean Licensor and any individual or Legal Entity
62 | on behalf of whom a Contribution has been received by Licensor and
63 | subsequently incorporated within the Work.
64 |
65 | 2. Grant of Copyright License. Subject to the terms and conditions of
66 | this License, each Contributor hereby grants to You a perpetual,
67 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
68 | copyright license to reproduce, prepare Derivative Works of,
69 | publicly display, publicly perform, sublicense, and distribute the
70 | Work and such Derivative Works in Source or Object form.
71 |
72 | 3. Grant of Patent License. Subject to the terms and conditions of
73 | this License, each Contributor hereby grants to You a perpetual,
74 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
75 | (except as stated in this section) patent license to make, have made,
76 | use, offer to sell, sell, import, and otherwise transfer the Work,
77 | where such license applies only to those patent claims licensable
78 | by such Contributor that are necessarily infringed by their
79 | Contribution(s) alone or by combination of their Contribution(s)
80 | with the Work to which such Contribution(s) was submitted. If You
81 | institute patent litigation against any entity (including a
82 | cross-claim or counterclaim in a lawsuit) alleging that the Work
83 | or a Contribution incorporated within the Work constitutes direct
84 | or contributory patent infringement, then any patent licenses
85 | granted to You under this License for that Work shall terminate
86 | as of the date such litigation is filed.
87 |
88 | 4. Redistribution. You may reproduce and distribute copies of the
89 | Work or Derivative Works thereof in any medium, with or without
90 | modifications, and in Source or Object form, provided that You
91 | meet the following conditions:
92 |
93 | (a) You must give any other recipients of the Work or
94 | Derivative Works a copy of this License; and
95 |
96 | (b) You must cause any modified files to carry prominent notices
97 | stating that You changed the files; and
98 |
99 | (c) You must retain, in the Source form of any Derivative Works
100 | that You distribute, all copyright, patent, trademark, and
101 | attribution notices from the Source form of the Work,
102 | excluding those notices that do not pertain to any part of
103 | the Derivative Works; and
104 |
105 | (d) If the Work includes a "NOTICE" text file as part of its
106 | distribution, then any Derivative Works that You distribute must
107 | include a readable copy of the attribution notices contained
108 | within such NOTICE file, excluding those notices that do not
109 | pertain to any part of the Derivative Works, in at least one
110 | of the following places: within a NOTICE text file distributed
111 | as part of the Derivative Works; within the Source form or
112 | documentation, if provided along with the Derivative Works; or,
113 | within a display generated by the Derivative Works, if and
114 | wherever such third-party notices normally appear. The contents
115 | of the NOTICE file are for informational purposes only and
116 | do not modify the License. You may add Your own attribution
117 | notices within Derivative Works that You distribute, alongside
118 | or as an addendum to the NOTICE text from the Work, provided
119 | that such additional attribution notices cannot be construed
120 | as modifying the License.
121 |
122 | You may add Your own copyright statement to Your modifications and
123 | may provide additional or different license terms and conditions
124 | for use, reproduction, or distribution of Your modifications, or
125 | for any such Derivative Works as a whole, provided Your use,
126 | reproduction, and distribution of the Work otherwise complies with
127 | the conditions stated in this License.
128 |
129 | 5. Submission of Contributions. Unless You explicitly state otherwise,
130 | any Contribution intentionally submitted for inclusion in the Work
131 | by You to the Licensor shall be under the terms and conditions of
132 | this License, without any additional terms or conditions.
133 | Notwithstanding the above, nothing herein shall supersede or modify
134 | the terms of any separate license agreement you may have executed
135 | with Licensor regarding such Contributions.
136 |
137 | 6. Trademarks. This License does not grant permission to use the trade
138 | names, trademarks, service marks, or product names of the Licensor,
139 | except as required for reasonable and customary use in describing the
140 | origin of the Work and reproducing the content of the NOTICE file.
141 |
142 | 7. Disclaimer of Warranty. Unless required by applicable law or
143 | agreed to in writing, Licensor provides the Work (and each
144 | Contributor provides its Contributions) on an "AS IS" BASIS,
145 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
146 | implied, including, without limitation, any warranties or conditions
147 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
148 | PARTICULAR PURPOSE. You are solely responsible for determining the
149 | appropriateness of using or redistributing the Work and assume any
150 | risks associated with Your exercise of permissions under this License.
151 |
152 | 8. Limitation of Liability. In no event and under no legal theory,
153 | whether in tort (including negligence), contract, or otherwise,
154 | unless required by applicable law (such as deliberate and grossly
155 | negligent acts) or agreed to in writing, shall any Contributor be
156 | liable to You for damages, including any direct, indirect, special,
157 | incidental, or consequential damages of any character arising as a
158 | result of this License or out of the use or inability to use the
159 | Work (including but not limited to damages for loss of goodwill,
160 | work stoppage, computer failure or malfunction, or any and all
161 | other commercial damages or losses), even if such Contributor
162 | has been advised of the possibility of such damages.
163 |
164 | 9. Accepting Warranty or Additional Liability. While redistributing
165 | the Work or Derivative Works thereof, You may choose to offer,
166 | and charge a fee for, acceptance of support, warranty, indemnity,
167 | or other liability obligations and/or rights consistent with this
168 | License. However, in accepting such obligations, You may act only
169 | on Your own behalf and on Your sole responsibility, not on behalf
170 | of any other Contributor, and only if You agree to indemnify,
171 | defend, and hold each Contributor harmless for any liability
172 | incurred by, or claims asserted against, such Contributor by reason
173 | of your accepting any such warranty or additional liability.
174 |
175 | END OF TERMS AND CONDITIONS
176 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | wsgiprox
2 | Copyright (C) 2017-2020 Webrecorder Software, Rhizome, and Contributors.
3 | Authored by Ilya Kreymer
4 |
5 | Distributed under the Apache License 2.0.
6 | See LICENSE for details.
7 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | wsgiprox
2 | ========
3 |
4 | .. image:: https://travis-ci.org/webrecorder/wsgiprox.svg?branch=master
5 | :target: https://travis-ci.org/webrecorder/wsgiprox
6 |
7 | ``wsgiprox`` is a Python WSGI middleware for adding HTTP and HTTPS proxy support to a WSGI application.
8 |
9 | The library accepts HTTP and HTTPS proxy connections, and routes them to a designated prefix.
10 |
11 | Usage
12 | ~~~~~
13 |
14 | For example, given a `WSGI `_ callable ``application``, the middleware could be defined as follows:
15 |
16 | .. code:: python
17 |
18 | from wsgiprox.wsgiprox import WSGIProxMiddleware
19 |
20 | application = WSGIProxMiddleware(application, '/prefix/', 'wsgiprox')
21 |
22 |
23 | With the above configuration, the middleware is configured to add a prefix of ``/prefix/`` to any url, unless it is to the proxy host ``wsgiprox``. Assuming a WSGI server running on port 8080, the middleware would translate HTTP/S proxy connections to a non-proxy WSGI request, and pass to the wrapped application:
24 |
25 | * Proxy Request: ``curl -x "localhost:8080" "http://example.com/path/file.html?A=B"``
26 |
27 | Becomes equivalent to: ``curl "http://localhost:8080/prefix/http://example.com/path/file.html?A=B"``
28 |
29 |
30 | * Proxy Request: ``curl -k -x "localhost:8080" "https://example.com/path/file.html?A=B"``
31 |
32 | Becomes equivalent to: ``curl "http://localhost:8080/prefix/https://example.com/path/file.html?A=B"``
33 |
34 | * Proxy Request to proxy host: ``curl -k -x "localhost:8080" "https://wsgiprox/path/file.html?A=B"``
35 |
36 | Not adding prefix for ``wsgiprox``, becomes equivalent to: ``curl -H "Host: wsgiprox" "http://localhost:8080/path/file.html?A=B"``
37 |
38 |
39 | All standard WSGI ``environ`` fields are set to the expected values for the translated url.
40 |
41 | When a request passes through wsgiprox middleware, ``environ['wsgiprox.proxy_host']`` is set to the proxy host.
42 | In this example, the WSGI app could check that ``environ.get('wsgiprox.proxy_host') == 'wsgiprox'`` to ensure that it was a proxy request. If the request is to the proxy host itself, then it is passed to the WSGI app without prefixing, and ``environ['wsgiprox.proxy_host'] == environ['HTTP_HOST']``
43 |
44 |
45 | Custom Resolvers
46 | ================
47 |
48 | The provided ``FixedResolver`` simply prepends a fixed prefix to each url. A custom resolver could compute the final url in a different way. The resolver instance is called with the full url, and the original WSGI ``environ``. The result is the translated ``REQUEST_URI`` that is passed to the WSGI applictaion.
49 |
50 | See `resolvers.py `_ for all available resolvers.
51 |
52 | For example, the following Resolver translates the url to a custom prefix based on the remote IP of the original request.
53 |
54 | .. code:: python
55 |
56 | class IPResolver(object):
57 | def __call__(self, url, environ):
58 | return '/' + environ['REMOTE_ADDR'] + '/' + url
59 |
60 | application = WSGIProxMiddleware(application, IPResolver())
61 |
62 |
63 | HTTPS CA
64 | ========
65 |
66 | To support HTTPS proxy, ``wsgiprox`` creates a custom CA (Certificate Authority), which must be accepted by the client (or it must ignore cert verification as with the ``-k`` option in CURL)
67 |
68 | By default, ``wsgiprox`` looks for CA .pem at: ``/ca/wsgiprox-ca.pem`` and auto-creates this bundle using the `certauth `_ library.
69 |
70 | The CA name and CA root cert filename can also be specified explicitly via ``proxy_options`` dict.
71 |
72 | By default, the following options are used:
73 |
74 | .. code:: python
75 |
76 | WSGIProxMiddleware(..., proxy_options={ca_name='wsgiprox https proxy CA',
77 | ca_file='./ca/wsgiprox-ca.pem'})
78 |
79 | The generated ``wsgiprox-ca.pem`` can be imported directly into most browsers directly as a trusted certificate authority, allowing the browser to accept HTTPS content proxied through ``wsgiprox``
80 |
81 | Downloading Certs
82 | =================
83 |
84 | The CA cert can be downloaded directly from the proxy directly. This allows for quick installation into a client/browser.
85 |
86 | * ``curl -x "localhost:8080" http://wsgiprox/download/pem`` will download in PEM format (for most platforms)
87 | * ``curl -x "localhost:8080" http://wsgiprox/download/p12`` will download in PKCS12 format (for Windows)
88 |
89 | The download host is the same as proxy main host, though can be changed via ``download_host`` param to WSGIProxMiddleware constructor.
90 |
91 | Custom Proxy Host Apps
92 | ======================
93 |
94 | It's is also possible to configure a custom WSGI app per proxy host, eg:
95 |
96 | * ``curl -x "localhost:8080" https://proxy-app-1/path/`` is passed to ``proxy-app-1``
97 | * ``curl -x "localhost:8080" https://proxy-app-2/foo`` is passed to ``proxy-app-2``
98 |
99 | This can be done via:
100 |
101 | .. code:: python
102 |
103 | from wsgiprox.wsgiprox import WSGIProxMiddleware
104 |
105 | proxy_apps = {"proxy-app-1": ProxyApp1WSGI(),
106 | "proxy-app-2": ProxyApp2WSGI(),
107 | "proxy-alias": None,
108 | }
109 |
110 | application = WSGIProxMiddleware(application, proxy_apps=apps)
111 |
112 | All other requests, or any requests not handled by the proxy app, are passed to the main ``application``.
113 |
114 | In the last case, since there is no proxy app, the request is passed directly to wrapped application.
115 | The ``wsgiprox.proxy_host`` would be set to ``'proxy-alias'`` instead of the default ``'wsgiprox'``, allowing the application to differentiate handling based on the value of ``wsgiprox.proxy_host``.
116 |
117 | Internally, the ``proxy_apps`` dict is used to configure the cert downloader app and default proxy host:
118 |
119 | .. code:: python
120 |
121 | proxy_apps['proxy_host'] = None
122 | proxy_apps['download_host'] = CertDownloader(self.ca)
123 |
124 |
125 | Websockets
126 | ==========
127 |
128 | ``wsgiprox`` optionally also supports proxying websockets, both unencryped ``ws://`` and via TLS ``wss://``. The websockets proxy functionality has primarily been tested with and requires the `gevent-websocket `_ library, and assumes that the wrapped WSGI application is also using this library for websocket support. Other implementations are not yet supported.
129 |
130 | To enable websocket proxying, install with ``pip install wsgiprox[gevent-websocket]`` which will install ``gevent-websocket``.
131 | To disable websocket proxying even with ``gevent-websocket`` installed, add ``proxy_options={'enable_websockets': False}``
132 |
133 | See the `test suite `_ for additional details.
134 |
135 |
136 | How it Works / A note about WSGI
137 | =================================
138 |
139 | ``wsgiprox`` supports several different proxying methods:
140 |
141 | * HTTP direct proxy, no tunnel
142 | * HTTP CONNECT tunnel for websockets, no SSL
143 | * HTTP CONNECT tunnel with SSL (also supports websockets)
144 |
145 | For regular HTTP proxy, wsgiprox simply rewrites a host-qualifed request such as ``GET http://example.com/``, and passes it along to underlying WSGI app.
146 |
147 | The other proxy methods involve the HTTP ``CONNECT`` verb and explicitly establishing a tunnel using the underlying socket. For HTTPS/SSL proxying, an SSL socket is established over the tunnel, while HTTP websocket proxy uses the underlying socket directly.
148 |
149 | The system thus relies on being able to access the underyling socket for the connection. As WSGI spec does not provide a way to do this, ``wsgiprox`` is not guaranteed to work under any WSGI server. The CONNECT verb creates a tunnel, and the tunneled connection is what is passed to the wrapped WSGI application. This is non-standard behavior and may not work on all WSGI servers.
150 |
151 | This middleware has been tested primarily with gevent WSGI server and uWSGI.
152 |
153 | There is also support for gunicorn and wsgiref, as they provide a way to access the underlying success. If the underlying socket can not be accessed, the ``CONNECT`` verb will fail with a 405.
154 |
155 | It may be possible to extend support to additional WSGI servers by extending ``WSGIProxMiddleware.get_raw_socket()`` to be able to find the underlying socket.
156 |
157 | Inspiration
158 | ~~~~~~~~~~~
159 |
160 | This project draws inspiration from a lot of previous efforts.
161 |
162 | Much of the functionality is a refactoring and spin-off of the proxy functionality in `pywb `_, which is built on top of standalone CA handling library `certauth `_.
163 |
164 | certauth was refactored from an earlier implementation in `warcprox `_ (which also inspired this name!).
165 |
166 | The certificate download feature was inspired by a similar feature available in `mitmprox `_
167 |
168 | License
169 | ~~~~~~~
170 |
171 | ``wsgiprox`` is licensed under the Apache 2.0 License and is part of the
172 | Webrecorder project.
173 |
174 | See `NOTICE `__ and `LICENSE `__ for details.
175 |
--------------------------------------------------------------------------------
/appveyor.yml:
--------------------------------------------------------------------------------
1 | environment:
2 | global:
3 | CMD_IN_ENV: "cmd /E:ON /V:ON /C obvci_appveyor_python_build_env.cmd"
4 |
5 | matrix:
6 | - PYTHON: "C:\\Python27"
7 | - PYTHON: "C:\\Python27-x64"
8 | - PYTHON: "C:\\Python34"
9 | - PYTHON: "C:\\Python34-x64"
10 | - PYTHON: "C:\\Python35"
11 | - PYTHON: "C:\\Python35-x64"
12 | - PYTHON: "C:\\Python36"
13 | - PYTHON: "C:\\Python36-x64"
14 | - PYTHON: "C:\\Python37"
15 | - PYTHON: "C:\\Python37-x64"
16 |
17 |
18 | install:
19 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
20 | - "python -m pip install --upgrade pip"
21 | - "pip install -U setuptools"
22 | - "pip install gevent-websocket pyopenssl"
23 | - "pip install coverage pytest-cov coveralls"
24 |
25 | build_script:
26 | - "python setup.py install"
27 |
28 | test_script:
29 | - "python setup.py test"
30 |
31 |
32 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # vim: set sw=4 et:
3 |
4 | from setuptools import setup, find_packages
5 | from setuptools.command.test import test as TestCommand
6 | import glob
7 |
8 | class PyTest(TestCommand):
9 | def finalize_options(self):
10 | TestCommand.finalize_options(self)
11 |
12 | def run_tests(self):
13 | import pytest
14 | import sys
15 | import os
16 | errcode = pytest.main(['--doctest-module', './wsgiprox', '--cov', 'wsgiprox', '-v', 'test/'])
17 | sys.exit(errcode)
18 |
19 |
20 | setup(
21 | name='wsgiprox',
22 | version='1.5.2',
23 | author='Ilya Kreymer',
24 | author_email='ikreymer@gmail.com',
25 | license='Apache 2.0',
26 | packages=find_packages(),
27 | url='https://github.com/webrecorder/wsgiprox',
28 | description='HTTP/S proxy with WebSockets over WSGI',
29 | long_description=open('README.rst').read(),
30 | provides=[
31 | 'wsgiprox'
32 | ],
33 | install_requires=[
34 | 'six',
35 | 'certauth>=1.2.1',
36 | ],
37 | zip_safe=True,
38 | data_files=[
39 | ],
40 | extras_require={
41 | 'gevent-websocket': ['gevent-websocket'],
42 | },
43 | entry_points="""
44 | [console_scripts]
45 | """,
46 | cmdclass={'test': PyTest},
47 | test_suite='',
48 | tests_require=[
49 | 'mock',
50 | 'pytest',
51 | 'pytest-cov',
52 | 'gevent',
53 | 'requests',
54 | 'websocket-client',
55 | 'waitress',
56 | ],
57 | classifiers=[
58 | 'Development Status :: 5 - Production/Stable',
59 | 'Environment :: Web Environment',
60 | 'License :: OSI Approved :: Apache Software License',
61 | 'Programming Language :: Python :: 2',
62 | 'Programming Language :: Python :: 2.7',
63 | 'Programming Language :: Python :: 3',
64 | 'Programming Language :: Python :: 3.4',
65 | 'Programming Language :: Python :: 3.5',
66 | 'Programming Language :: Python :: 3.6',
67 | 'Programming Language :: Python :: 3.7',
68 | 'Topic :: Software Development :: Libraries :: Python Modules',
69 | 'Topic :: Utilities',
70 | ]
71 | )
72 |
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webrecorder/wsgiprox/004870a87959e68ff28ff4362e4f0df28ec22030/test/__init__.py
--------------------------------------------------------------------------------
/test/fixture_app.py:
--------------------------------------------------------------------------------
1 | from six.moves.urllib.parse import parse_qsl
2 | import os
3 | import six
4 |
5 |
6 | # ============================================================================
7 | class CustomApp(object):
8 | def __call__(self, env, start_response):
9 | result = 'Custom App: ' + env['wsgiprox.proxy_host'] + ' req to ' + env['PATH_INFO']
10 | result = result.encode('iso-8859-1')
11 |
12 | headers = [('Content-Length', str(len(result)))]
13 |
14 | start_response('200 OK', headers=headers)
15 |
16 | return iter([result])
17 |
18 |
19 | # ============================================================================
20 | class ClosingTestReader(six.Iterator):
21 | stream_closed = False
22 |
23 | def __init__(self, buff):
24 | self.started = False
25 | self.buff = buff
26 | ClosingTestReader.stream_closed = False
27 |
28 | def close(self):
29 | ClosingTestReader.stream_closed = True
30 |
31 | def __iter__(self):
32 | return self
33 |
34 | def __next__(self):
35 | if not self.started:
36 | self.started = True
37 | return self.buff
38 | else:
39 | raise StopIteration()
40 |
41 |
42 | # ============================================================================
43 | class TestWSGI(object):
44 | def __call__(self, env, start_response):
45 | status = '200 OK'
46 |
47 | params = dict(parse_qsl(env.get('QUERY_STRING')))
48 |
49 | ws = env.get('wsgi.websocket')
50 | if ws and not params.get('ignore_ws'):
51 | msg = 'WS Request Url: ' + env.get('REQUEST_URI', '')
52 | msg += ' Echo: ' + ws.receive()
53 | ws.send(msg)
54 | return []
55 |
56 | if params.get('stream') == 'true':
57 | result = ClosingTestReader(params.get('data').encode('utf-8'))
58 | start_response(status, [])
59 | return result
60 |
61 | result = 'Requested Url: ' + env.get('REQUEST_URI', '')
62 |
63 | if env['REQUEST_METHOD'] == 'POST':
64 | result += ' Post Data: ' + env['wsgi.input'].read(int(env['CONTENT_LENGTH'])).decode('utf-8')
65 |
66 | if params.get('addproxyhost') == 'true':
67 | result += ' Proxy Host: ' + env.get('wsgiprox.proxy_host', '')
68 |
69 | result = result.encode('iso-8859-1')
70 |
71 | if params.get('chunked') == 'true':
72 | headers = []
73 | else:
74 | headers = [('Content-Length', str(len(result)))]
75 |
76 | write = start_response(status, headers)
77 |
78 | if params.get('write') == 'true':
79 | write(result)
80 | return iter([])
81 | else:
82 | return iter([result])
83 |
84 |
85 | # ============================================================================
86 | def make_application(test_ca_file=None, proxy_options=None):
87 | proxy_options = proxy_options or {}
88 | if test_ca_file is None:
89 | test_ca_file = os.environ.get('CA_ROOT_FILE',
90 | os.path.join('.', 'wsgiprox-ca-test.pem'))
91 |
92 | proxy_options['ca_name'] = 'wsgiprox test ca'
93 | proxy_options['ca_file_cache'] = test_ca_file
94 |
95 | from wsgiprox.wsgiprox import WSGIProxMiddleware
96 | return WSGIProxMiddleware(TestWSGI(),
97 | '/prefix/',
98 | proxy_options=proxy_options,
99 | proxy_apps={'proxy-alias': '',
100 | 'proxy-app-1': CustomApp()
101 | }
102 | )
103 |
104 |
105 | # ============================================================================
106 | try:
107 | import uwsgi
108 | application = make_application()
109 | except:
110 | pass
111 |
112 |
113 | # ============================================================================
114 | if __name__ == "__main__":
115 | from gevent.pywsgi import WSGIServer
116 | from gevent.monkey import patch_all; patch_all()
117 |
118 | application = make_application()
119 | WSGIServer(('localhost', 8080), application).serve_forever()
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/test/test_wsgiprox.py:
--------------------------------------------------------------------------------
1 | from gevent.monkey import patch_all; patch_all()
2 | from gevent.pywsgi import WSGIServer
3 |
4 | import gevent
5 |
6 | import sys
7 |
8 |
9 | # Fix for KEEP_CNT in Windows, per (https://bugs.python.org/issue32394#msg308943)
10 | if sys.version_info >= (3,6,4) and hasattr(sys, 'getwindowsversion') and sys.getwindowsversion()[0] < 10:
11 | import socket
12 |
13 | if hasattr(socket, 'TCP_KEEPCNT'):
14 | del socket.TCP_KEEPCNT
15 |
16 | if hasattr(socket, 'TCP_FASTOPEN'):
17 | del socket.TCP_FASTOPEN
18 |
19 |
20 | import requests
21 | import websocket
22 | import pytest
23 | import subprocess
24 |
25 | from wsgiprox.resolvers import ProxyAuthResolver
26 |
27 | from mock import patch
28 |
29 | import shutil
30 | import six
31 | import os
32 | import tempfile
33 | import re
34 |
35 | from six.moves.http_client import HTTPSConnection, HTTPConnection
36 |
37 | from io import BytesIO
38 |
39 |
40 | # ============================================================================
41 | @pytest.fixture(params=['http', 'https'])
42 | def scheme(request):
43 | return request.param
44 |
45 |
46 | @pytest.fixture(params=['ws', 'wss'])
47 | def ws_scheme(request):
48 | return request.param
49 |
50 |
51 | # ============================================================================
52 | class BaseWSGIProx(object):
53 | @classmethod
54 | def setup_class(cls):
55 | cls.test_ca_dir = tempfile.mkdtemp()
56 | cls.root_ca_file = os.path.join(cls.test_ca_dir, 'wsgiprox-ca-test.pem')
57 |
58 | from .fixture_app import make_application
59 | cls.app = make_application(cls.root_ca_file)
60 |
61 | cls.sesh = requests.session()
62 |
63 | @classmethod
64 | def teardown_class(cls):
65 | shutil.rmtree(cls.test_ca_dir)
66 |
67 | @classmethod
68 | def proxy_dict(cls, port, host='localhost'):
69 | return {'http': 'http://{0}:{1}'.format(host, port),
70 | 'https': 'https://{0}:{1}'.format(host, port)
71 | }
72 |
73 | def _init_ws(self):
74 | ws = websocket.WebSocket(sslopt={'ca_certs': self.root_ca_file,
75 | 'ca_cert': self.root_ca_file})
76 | return ws
77 |
78 | def test_in_mem_ca(self):
79 | from .fixture_app import make_application
80 | ca_dict = {}
81 | app = make_application(ca_dict)
82 | assert ca_dict != {}
83 | assert app.root_ca_file == None
84 |
85 | def test_non_chunked(self, scheme):
86 | res = self.sesh.get('{0}://example.com/path/file?foo=bar&addproxyhost=true'.format(scheme),
87 | proxies=self.proxies,
88 | verify=self.root_ca_file)
89 |
90 | assert(res.headers['Content-Length'] != '')
91 | assert(res.text == 'Requested Url: /prefix/{0}://example.com/path/file?foo=bar&addproxyhost=true Proxy Host: wsgiprox'.format(scheme))
92 |
93 | def test_non_chunked_custom_port(self, scheme):
94 | res = self.sesh.get('{0}://example.com:123/path/file?foo=bar&addproxyhost=true'.format(scheme),
95 | proxies=self.proxies,
96 | verify=self.root_ca_file)
97 |
98 | assert(res.headers['Content-Length'] != '')
99 | assert(res.text == 'Requested Url: /prefix/{0}://example.com:123/path/file?foo=bar&addproxyhost=true Proxy Host: wsgiprox'.format(scheme))
100 |
101 | def test_non_chunked_ip_port(self, scheme):
102 | res = self.sesh.get('{0}://10.0.1.10:7890/path/file?foo=bar&addproxyhost=true'.format(scheme),
103 | proxies=self.proxies,
104 | verify=self.root_ca_file)
105 |
106 | assert(res.headers['Content-Length'] != '')
107 | assert(res.text == 'Requested Url: /prefix/{0}://10.0.1.10:7890/path/file?foo=bar&addproxyhost=true Proxy Host: wsgiprox'.format(scheme))
108 |
109 | @pytest.mark.skipif(sys.version_info >= (3,0) and sys.version_info < (3,4),
110 | reason='Not supported in py3.3')
111 | def test_with_sni(self):
112 | import ssl
113 | conn = SNIHTTPSConnection('localhost', self.port, context=ssl.create_default_context(cafile=self.root_ca_file))
114 | # set CONNECT host:port
115 | conn.set_tunnel('93.184.216.34', 443)
116 | # set actual hostname
117 | conn._server_hostname = 'example.com'
118 | conn.request('GET', '/path/file?foo=bar&addproxyhost=true')
119 | res = conn.getresponse()
120 | text = res.read().decode('utf-8')
121 | conn.close()
122 |
123 | assert(res.getheader('Content-Length') != '')
124 | assert(text == 'Requested Url: /prefix/https://example.com/path/file?foo=bar&addproxyhost=true Proxy Host: wsgiprox')
125 |
126 | def test_chunked(self, scheme):
127 | res = self.sesh.get('{0}://example.com/path/file?foo=bar&chunked=true'.format(scheme),
128 | proxies=self.proxies,
129 | verify=self.root_ca_file)
130 |
131 | if not (self.server_type == 'uwsgi' and scheme == 'http'):
132 | assert(res.headers['Transfer-Encoding'] == 'chunked')
133 | assert(res.headers.get('Content-Length') == None)
134 | assert(res.text == 'Requested Url: /prefix/{0}://example.com/path/file?foo=bar&chunked=true'.format(scheme))
135 |
136 | def test_stream_data_chunked(self, scheme):
137 | from .fixture_app import ClosingTestReader
138 | assert ClosingTestReader.stream_closed == False
139 |
140 | res = self.sesh.get('{0}://example.com/path/filename?stream=true&data=Streaming Data: Some Data'.format(scheme),
141 | proxies=self.proxies,
142 | verify=self.root_ca_file)
143 |
144 | if not (self.server_type == 'uwsgi' and scheme == 'http'):
145 | assert(res.headers['Transfer-Encoding'] == 'chunked')
146 | assert(res.headers.get('Content-Length') == None)
147 | assert(res.text == 'Streaming Data: Some Data')
148 |
149 | # only checkeable if not uwsgi, otherwise in separate process
150 | if self.server_type != 'uwsgi':
151 | assert ClosingTestReader.stream_closed == True
152 | ClosingTestReader.stream_closed = False
153 |
154 | @patch('six.moves.http_client.HTTPConnection._http_vsn', 10)
155 | @patch('six.moves.http_client.HTTPConnection._http_vsn_str', 'HTTP/1.0')
156 | def test_chunked_force_http10_buffer(self, scheme):
157 | res = requests.get('{0}://example.com/path/file?foo=bar&chunked=true'.format(scheme),
158 | proxies=self.proxies,
159 | verify=self.root_ca_file)
160 |
161 | assert(res.headers.get('Transfer-Encoding') == None)
162 |
163 | # https, must buffer and set content-length to avoid breaking CONNECT envelope
164 | # for http, up-to wsgi server if buffering
165 | if scheme == 'https':
166 | assert(res.headers['Content-Length'] != '')
167 | assert(res.text == 'Requested Url: /prefix/{0}://example.com/path/file?foo=bar&chunked=true'.format(scheme))
168 |
169 | def test_write_callable(self, scheme):
170 | res = self.sesh.get('{0}://example.com/path/file?foo=bar&write=true'.format(scheme),
171 | proxies=self.proxies,
172 | verify=self.root_ca_file)
173 |
174 | assert(res.text == 'Requested Url: /prefix/{0}://example.com/path/file?foo=bar&write=true'.format(scheme))
175 |
176 | def test_post(self, scheme):
177 | res = requests.post('{0}://example.com/path/post'.format(scheme), data=BytesIO(b'ABC=1&xyz=2'),
178 | proxies=self.proxies,
179 | verify=self.root_ca_file)
180 |
181 | assert(res.text == 'Requested Url: /prefix/{0}://example.com/path/post Post Data: ABC=1&xyz=2'.format(scheme))
182 |
183 | def test_fixed_host(self, scheme):
184 | res = self.sesh.get('{0}://wsgiprox/path/file?foo=bar'.format(scheme),
185 | proxies=self.proxies,
186 | verify=self.root_ca_file)
187 |
188 | assert(res.text == 'Requested Url: /path/file?foo=bar')
189 |
190 | def test_alt_host(self, scheme):
191 | res = self.sesh.get('{0}://proxy-alias/path/file?foo=bar&addproxyhost=true'.format(scheme),
192 | proxies=self.proxies,
193 | verify=self.root_ca_file)
194 |
195 | assert(res.text == 'Requested Url: /path/file?foo=bar&addproxyhost=true Proxy Host: proxy-alias')
196 |
197 | def test_proxy_app(self, scheme):
198 | res = self.sesh.get('{0}://proxy-app-1/path/file'.format(scheme),
199 | proxies=self.proxies,
200 | verify=self.root_ca_file)
201 |
202 | assert(res.text == 'Custom App: proxy-app-1 req to /path/file')
203 |
204 | def test_download_pem(self, scheme):
205 | res = self.sesh.get('{0}://wsgiprox/download/pem'.format(scheme),
206 | proxies=self.proxies,
207 | verify=self.root_ca_file)
208 |
209 | assert res.headers['content-type'] == 'application/x-x509-ca-cert'
210 |
211 | def test_download_pkcs12(self, scheme):
212 | res = self.sesh.get('{0}://wsgiprox/download/p12'.format(scheme),
213 | proxies=self.proxies,
214 | verify=self.root_ca_file)
215 |
216 | assert res.headers['content-type'] == 'application/x-pkcs12'
217 |
218 | def test_websocket(self, ws_scheme):
219 | scheme = ws_scheme.replace('ws', 'http')
220 | pytest.importorskip('geventwebsocket.handler')
221 |
222 | ws = self._init_ws()
223 | ws.connect('{0}://example.com/websocket?a=b'.format(ws_scheme),
224 | http_proxy_host='localhost',
225 | http_proxy_port=self.port)
226 |
227 | ws.send('{0} message'.format(ws_scheme))
228 | msg = ws.recv()
229 | assert(msg == 'WS Request Url: /prefix/{0}://example.com/websocket?a=b Echo: {1} message'.format(scheme, ws_scheme))
230 |
231 | def test_websocket_custom_port(self, ws_scheme):
232 | scheme = ws_scheme.replace('ws', 'http')
233 | pytest.importorskip('geventwebsocket.handler')
234 |
235 | ws = self._init_ws()
236 | ws.connect('{0}://example.com:456/websocket?a=b'.format(ws_scheme),
237 | http_proxy_host='localhost',
238 | http_proxy_port=self.port)
239 |
240 | ws.send('{0} message'.format(ws_scheme))
241 | msg = ws.recv()
242 | assert(msg == 'WS Request Url: /prefix/{0}://example.com:456/websocket?a=b Echo: {1} message'.format(scheme, ws_scheme))
243 |
244 | def test_websocket_fixed_host(self, ws_scheme):
245 | scheme = ws_scheme.replace('ws', 'http')
246 | pytest.importorskip('geventwebsocket.handler')
247 |
248 | ws = self._init_ws()
249 | ws.connect('{0}://wsgiprox/websocket?a=b'.format(ws_scheme),
250 | http_proxy_host='localhost',
251 | http_proxy_port=self.port)
252 |
253 | ws.send('{0} message'.format(ws_scheme))
254 | msg = ws.recv()
255 | assert(msg == 'WS Request Url: /websocket?a=b Echo: {1} message'.format(scheme, ws_scheme))
256 |
257 | def test_error_websocket_ignored(self, ws_scheme):
258 | scheme = ws_scheme.replace('ws', 'http')
259 | pytest.importorskip('geventwebsocket.handler')
260 |
261 | ws = self._init_ws()
262 | ws.connect('{0}://wsgiprox/websocket?ignore_ws=true'.format(ws_scheme),
263 | http_proxy_host='localhost',
264 | http_proxy_port=self.port)
265 |
266 | ws.send('{0} message'.format(ws_scheme))
267 | ws.settimeout(0.2)
268 | with pytest.raises(Exception):
269 | msg = ws.recv()
270 |
271 | def test_non_proxy_passthrough(self):
272 | res = self.sesh.get('http://localhost:' + str(self.port) + '/path/file?foo=bar')
273 | assert(res.text == 'Requested Url: /path/file?foo=bar')
274 |
275 |
276 | # ============================================================================
277 | class Test_gevent_WSGIProx(BaseWSGIProx):
278 | @classmethod
279 | def setup_class(cls):
280 | super(Test_gevent_WSGIProx, cls).setup_class()
281 | cls.server = WSGIServer(('localhost', 0), cls.app)
282 | cls.server.init_socket()
283 | cls.port = str(cls.server.address[1])
284 |
285 | gevent.spawn(cls.server.serve_forever)
286 |
287 | cls.proxies = cls.proxy_dict(cls.port)
288 |
289 | cls.auth_resolver = ProxyAuthResolver()
290 |
291 | cls.server_type = 'gevent'
292 |
293 | cls.sesh_2 = requests.session()
294 |
295 | def test_proxy_auth_required(self, scheme):
296 | self.app.prefix_resolver = self.auth_resolver
297 |
298 | with pytest.raises(requests.exceptions.RequestException) as err:
299 | res = self.sesh_2.get('{0}://example.com/path/file?foo=bar'.format(scheme),
300 | proxies=self.proxies)
301 |
302 | res.raise_for_status()
303 |
304 | assert '407 ' in str(err.value)
305 |
306 | def test_proxy_auth_success(self, scheme):
307 | self.app.prefix_resolver = self.auth_resolver
308 |
309 | proxies = self.proxy_dict(self.port, 'other-prefix:ignore@localhost')
310 |
311 | res = self.sesh_2.get('{0}://example.com/path/file?foo=bar'.format(scheme),
312 | proxies=proxies,
313 | verify=self.root_ca_file)
314 |
315 | assert(res.text == 'Requested Url: /other-prefix/{0}://example.com/path/file?foo=bar'.format(scheme))
316 |
317 | def test_error_proxy_unsupported(self):
318 | from waitress.server import create_server
319 | server = create_server(self.app, host='127.0.0.1', port=0)
320 |
321 | port = server.effective_port
322 |
323 | gevent.spawn(server.run)
324 |
325 | proxies = self.proxy_dict(port)
326 |
327 | # http proxy not supported: just passes through
328 | res = self.sesh_2.get('http://example.com/path/file?foo=bar',
329 | proxies=proxies,
330 | verify=self.root_ca_file)
331 |
332 | assert(res.text == 'Requested Url: /path/file?foo=bar')
333 |
334 | # https proxy (via CONNECT) not supported
335 | with pytest.raises(requests.exceptions.ProxyError) as err:
336 | res = self.sesh_2.get('https://example.com/path/file?foo=bar',
337 | proxies=proxies,
338 | verify=self.root_ca_file)
339 |
340 | assert '405 ' in str(err.value)
341 |
342 |
343 | # ============================================================================
344 | @pytest.mark.skipif(sys.platform == 'win32', reason='no uwsgi on windows')
345 | class Test_uwsgi_WSGIProx(BaseWSGIProx):
346 | @classmethod
347 | def setup_class(cls):
348 | super(Test_uwsgi_WSGIProx, cls).setup_class()
349 |
350 | env = os.environ.copy()
351 | env['CA_ROOT_FILE'] = cls.root_ca_file
352 |
353 | curr_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)))
354 |
355 | try:
356 | cls.uwsgi = subprocess.Popen(['uwsgi', 'uwsgi.ini'], env=env, cwd=curr_dir,
357 | stderr=subprocess.PIPE)
358 |
359 | except Exception as e:
360 | pytest.skip('uwsgi not found, skipping uwsgi tests')
361 |
362 | port_rx = re.compile(r'uwsgi socket 0 bound to TCP address :([\d]+)')
363 |
364 | while True:
365 | line = cls.uwsgi.stderr.readline().decode('utf-8')
366 | m = port_rx.search(line)
367 | if m:
368 | cls.port = int(m.group(1))
369 | break
370 |
371 | cls.proxies = cls.proxy_dict(cls.port)
372 |
373 | cls.server_type = 'uwsgi'
374 |
375 | @classmethod
376 | def teardown_class(cls):
377 | cls.uwsgi.terminate()
378 | super(Test_uwsgi_WSGIProx, cls).teardown_class()
379 |
380 |
381 |
382 | # ============================================================================
383 | class SNIHTTPSConnection(HTTPSConnection):
384 | def connect(self):
385 | HTTPConnection.connect(self)
386 |
387 | server_hostname = self._server_hostname
388 |
389 | self.sock = self._context.wrap_socket(self.sock,
390 | server_hostname=self._server_hostname)
391 |
392 |
393 |
--------------------------------------------------------------------------------
/test/uwsgi.ini:
--------------------------------------------------------------------------------
1 | [uwsgi]
2 | http-socket = :0
3 | master = true
4 |
5 | http-auto-chunked = true
6 |
7 | die-on-term = true
8 |
9 | if-env = VIRTUAL_ENV
10 | venv = $(VIRTUAL_ENV)
11 | endif =
12 |
13 | env = CA_ROOT_DIR=$(CA_ROOT_DIR)
14 |
15 | processes = 1
16 | gevent = 10
17 |
18 | gevent-early-monkey-patch =
19 |
20 | wsgi-file = fixture_app.py
21 |
22 |
23 |
--------------------------------------------------------------------------------
/wsgiprox/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webrecorder/wsgiprox/004870a87959e68ff28ff4362e4f0df28ec22030/wsgiprox/__init__.py
--------------------------------------------------------------------------------
/wsgiprox/gevent_ssl.py:
--------------------------------------------------------------------------------
1 | """
2 | vendored from https://github.com/wolever/gevent_openssl
3 | with additional fixes
4 | """
5 |
6 | import OpenSSL.SSL
7 | from gevent.socket import wait_read, wait_write
8 |
9 | _real_connection = OpenSSL.SSL.Connection
10 |
11 |
12 | class SSLConnection(object):
13 | """OpenSSL Connection wrapper
14 | """
15 |
16 | _reverse_mapping = _real_connection._reverse_mapping
17 |
18 | def __init__(self, context, sock):
19 | self._context = context
20 | self._sock = sock
21 | self._connection = _real_connection(context, sock)
22 |
23 | def __getattr__(self, attr):
24 | return getattr(self._connection, attr)
25 |
26 | def __iowait(self, io_func, *args, **kwargs):
27 | fd = self._sock.fileno()
28 | timeout = self._sock.gettimeout()
29 | while True:
30 | try:
31 | return io_func(*args, **kwargs)
32 | except (OpenSSL.SSL.WantReadError, OpenSSL.SSL.WantX509LookupError):
33 | wait_read(fd, timeout=timeout)
34 | except OpenSSL.SSL.WantWriteError:
35 | wait_write(fd, timeout=timeout)
36 | except OpenSSL.SSL.SysCallError as e:
37 | if e.args == (-1, 'Unexpected EOF'):
38 | return b''
39 | raise
40 |
41 | #def accept(self):
42 | # sock, addr = self._sock.accept()
43 | # return Connection(self._context, sock), addr
44 |
45 | def do_handshake(self):
46 | return self.__iowait(self._connection.do_handshake)
47 |
48 | #def connect(self, *args, **kwargs):
49 | # return self.__iowait(self._connection.connect, *args, **kwargs)
50 |
51 | def send(self, data, flags=0):
52 | return self.__send(self._connection.send, data, flags)
53 |
54 | def sendall(self, data, flags=0):
55 | # Note: all of the types supported by OpenSSL's Connection.sendall,
56 | # basestring, memoryview, and buffer, support len(...) and slicing,
57 | # so they are safe to use here.
58 |
59 | # pyopenssl doesn't suport sending bytearrays yet
60 | if isinstance(data, bytearray):
61 | data = bytes(data)
62 |
63 | while len(data) > 0:
64 | # cast to bytes
65 | res = self.send(data, flags)
66 | data = data[res:]
67 |
68 | def __send(self, send_method, data, flags=0):
69 | return self.__iowait(send_method, data, flags)
70 |
71 | def recv(self, bufsiz, flags=0):
72 | pending = self._connection.pending()
73 | if pending:
74 | return self._connection.recv(min(pending, bufsiz))
75 | try:
76 | return self.__iowait(self._connection.recv, bufsiz, flags)
77 | except OpenSSL.SSL.ZeroReturnError:
78 | return b''
79 |
80 | def shutdown(self):
81 | try:
82 | return self.__iowait(self._connection.shutdown)
83 | except OpenSSL.SSL.SysCallError as e:
84 | return False
85 |
86 |
87 |
--------------------------------------------------------------------------------
/wsgiprox/resolvers.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import six
3 |
4 |
5 | # ============================================================================
6 | class FixedResolver(object):
7 | def __init__(self, fixed_prefix='/'):
8 | self.fixed_prefix = fixed_prefix
9 |
10 | def __call__(self, url, env):
11 | return self.fixed_prefix + url
12 |
13 |
14 | # ============================================================================
15 | class ProxyAuthResolver(object):
16 | DEFAULT_MSG = 'Please enter prefix path'
17 |
18 | def __init__(self, auth_msg=None):
19 | self.auth_msg = auth_msg or self.DEFAULT_MSG
20 |
21 | def __call__(self, url, env):
22 | proxy_auth = env.get('HTTP_PROXY_AUTHORIZATION')
23 |
24 | user_pass = self.read_basic_auth(proxy_auth)
25 |
26 | return '/' + user_pass.split(':')[0] + '/' + url
27 |
28 | def require_auth(self, env):
29 | proxy_auth = env.get('HTTP_PROXY_AUTHORIZATION')
30 |
31 | if not proxy_auth:
32 | return self.auth_msg
33 |
34 | return None
35 |
36 | def read_basic_auth(self, value):
37 | user_pass = ''
38 | parts = value.split(' ', 1)
39 |
40 | if parts[0].lower() == 'basic' and len(parts) == 2:
41 | user_pass = base64.b64decode(parts[1].encode('utf-8'))
42 |
43 | if six.PY3: #pragma: no cover
44 | user_pass = user_pass.decode('utf-8')
45 |
46 | return user_pass
47 |
48 |
--------------------------------------------------------------------------------
/wsgiprox/wsgiprox.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import socket
4 | import ssl
5 |
6 | from six.moves.urllib.parse import quote, urlsplit
7 | from tempfile import SpooledTemporaryFile
8 |
9 | import six
10 | import os
11 | import time
12 | import io
13 | import logging
14 |
15 | from certauth.certauth import CertificateAuthority
16 |
17 | from OpenSSL import SSL
18 |
19 | from wsgiprox.resolvers import FixedResolver
20 |
21 |
22 | try:
23 | from geventwebsocket.handler import WebSocketHandler
24 | except: #pragma: no cover
25 | WebSocketHandler = object
26 |
27 | BUFF_SIZE = 16384
28 |
29 | logger = logging.getLogger(__file__)
30 |
31 |
32 | # ============================================================================
33 | class WrappedWebSockHandler(WebSocketHandler):
34 | def __init__(self, connect_handler):
35 | self.environ = connect_handler.environ
36 | self.start_response = connect_handler.start_response
37 | self.request_version = 'HTTP/1.1'
38 |
39 | self.socket = connect_handler.curr_sock
40 | self.rfile = connect_handler.reader
41 |
42 | class FakeServer(object):
43 | def __init__(self):
44 | self.application = {}
45 |
46 | self.server = FakeServer()
47 |
48 | @property
49 | def logger(self):
50 | return logger
51 |
52 |
53 | # ============================================================================
54 | class BaseHandler(object):
55 | FILTER_REQ_HEADERS = ('HTTP_PROXY_CONNECTION',
56 | 'HTTP_PROXY_AUTHORIZATION')
57 |
58 | @classmethod
59 | def chunk_encode(cls, orig_iter):
60 | for chunk in orig_iter:
61 | chunk_len = len(chunk)
62 | if chunk_len:
63 | yield ('%X\r\n' % chunk_len).encode()
64 | yield chunk
65 | yield b'\r\n'
66 |
67 | yield b'0\r\n\r\n'
68 |
69 | @classmethod
70 | def buffer_iter(cls, orig_iter, buff_size=65536):
71 | out = SpooledTemporaryFile(buff_size)
72 | size = 0
73 |
74 | for buff in orig_iter:
75 | size += len(buff)
76 | out.write(buff)
77 |
78 | content_length_str = str(size)
79 | out.seek(0)
80 |
81 | def read_iter():
82 | while True:
83 | buff = out.read(buff_size)
84 | if not buff:
85 | break
86 | yield buff
87 |
88 | return content_length_str, read_iter()
89 |
90 |
91 | # ============================================================================
92 | class SocketReader(io.BufferedIOBase):
93 | def __init__(self, socket):
94 | self.socket = socket
95 |
96 | def readable(self):
97 | return True
98 |
99 | def read(self, size):
100 | return self.socket.recv(size)
101 |
102 |
103 | # ============================================================================
104 | class SocketWriter(object):
105 | def __init__(self, socket):
106 | self.socket = socket
107 |
108 | def write(self, buff):
109 | return self.socket.sendall(buff)
110 |
111 |
112 | # ============================================================================
113 | class ConnectHandler(BaseHandler):
114 | def __init__(self, curr_sock, scheme, wsgi, resolve):
115 | self.curr_sock = curr_sock
116 | self.scheme = scheme
117 |
118 | self.wsgi = wsgi
119 | self.resolve = resolve
120 |
121 | reader = SocketReader(curr_sock)
122 | self.reader = io.BufferedReader(reader, BUFF_SIZE)
123 | self.writer = SocketWriter(curr_sock)
124 |
125 | self.is_keepalive = True
126 |
127 | def __call__(self, environ, enable_ws):
128 | self._chunk = False
129 | self._buffer = False
130 | self.headers_finished = False
131 |
132 | self.convert_environ(environ)
133 |
134 | # check for websocket upgrade, if enabled
135 | if enable_ws and self.environ.get('HTTP_UPGRADE', '') == 'websocket':
136 | self.handle_ws()
137 | else:
138 | self.finish_response()
139 |
140 | self.is_keepalive = self.environ.get('HTTP_CONNECTION', '') == 'keep-alive'
141 |
142 | def write(self, data):
143 | self.finish_headers()
144 | self.writer.write(data)
145 |
146 | def finish_headers(self):
147 | if not self.headers_finished:
148 | self.writer.write(b'\r\n')
149 | self.headers_finished = True
150 |
151 | def start_response(self, statusline, headers, exc_info=None):
152 | protocol = self.environ.get('SERVER_PROTOCOL', 'HTTP/1.0')
153 | status_line = protocol + ' ' + statusline + '\r\n'
154 | self.writer.write(status_line.encode('iso-8859-1'))
155 |
156 | found_cl = False
157 |
158 | for name, value in headers:
159 | if not found_cl and name.lower() == 'content-length':
160 | found_cl = True
161 |
162 | line = name + ': ' + value + '\r\n'
163 | self.writer.write(line.encode('iso-8859-1'))
164 |
165 | if not found_cl:
166 | if protocol == 'HTTP/1.1':
167 | self.writer.write(b'Transfer-Encoding: chunked\r\n')
168 | self._chunk = True
169 | else:
170 | self._buffer = True
171 |
172 | return self.write
173 |
174 | def finish_response(self):
175 | resp_iter = self.wsgi(self.environ, self.start_response)
176 | orig_resp_iter = resp_iter
177 |
178 | try:
179 | if self._chunk:
180 | resp_iter = self.chunk_encode(resp_iter)
181 |
182 | elif self._buffer and not self.headers_finished:
183 | cl, resp_iter = self.buffer_iter(resp_iter)
184 | self.writer.write(b'Content-Length: ' + cl.encode() + b'\r\n')
185 |
186 | # finish headers after wsgi call
187 | self.finish_headers()
188 |
189 | for obj in resp_iter:
190 | self.writer.write(obj)
191 |
192 | finally:
193 | # ensure original response iter is closed if it has a close()
194 | if orig_resp_iter and hasattr(orig_resp_iter, 'close'):
195 | orig_resp_iter.close()
196 |
197 | def close(self):
198 | self.reader.close()
199 |
200 | def handle_ws(self):
201 | ws = WrappedWebSockHandler(self)
202 | result = ws.upgrade_websocket()
203 |
204 | # start_response() already called in upgrade_websocket()
205 | # flush headers before starting wsgi
206 | self.finish_headers()
207 |
208 | # wsgi expected to access established 'wsgi.websocket'
209 |
210 | # do-nothing start-response
211 | def ignore_sr(s, h, e=None):
212 | return []
213 |
214 | self.wsgi(self.environ, ignore_sr)
215 |
216 | def convert_environ(self, environ):
217 | self.environ = environ.copy()
218 |
219 | statusline = self.reader.readline().rstrip()
220 |
221 | if six.PY3: #pragma: no cover
222 | statusline = statusline.decode('iso-8859-1')
223 |
224 | statusparts = statusline.split(' ', 2)
225 | hostname = self.environ['wsgiprox.connect_host']
226 |
227 | if len(statusparts) < 3:
228 | raise Exception('Invalid Proxy Request Line: length={0} from='.format(len(statusline), hostname))
229 |
230 | self.environ['wsgi.url_scheme'] = self.scheme
231 |
232 | self.environ['REQUEST_METHOD'] = statusparts[0]
233 |
234 | self.environ['SERVER_PROTOCOL'] = statusparts[2].strip()
235 |
236 | full_uri = self.scheme + '://' + hostname
237 | port = self.environ.get('wsgiprox.connect_port', '')
238 | if port:
239 | full_uri += ':' + port
240 |
241 | full_uri += statusparts[1]
242 |
243 | self.resolve(full_uri, self.environ, hostname)
244 |
245 | while True:
246 | line = self.reader.readline()
247 | if line:
248 | line = line.rstrip()
249 | if six.PY3: #pragma: no cover
250 | line = line.decode('iso-8859-1')
251 |
252 | if not line:
253 | break
254 |
255 | parts = line.split(':', 1)
256 | if len(parts) < 2:
257 | continue
258 |
259 | name = parts[0].strip()
260 | value = parts[1].strip()
261 |
262 | name = name.replace('-', '_').upper()
263 |
264 | if name not in ('CONTENT_LENGTH', 'CONTENT_TYPE'):
265 | name = 'HTTP_' + name
266 |
267 | if name not in self.FILTER_REQ_HEADERS:
268 | self.environ[name] = value
269 |
270 | self.environ['wsgi.input'] = self.reader
271 |
272 |
273 | # ============================================================================
274 | class HttpProxyHandler(BaseHandler):
275 | PROXY_CONN_CLOSE = ('Proxy-Connection', 'close')
276 |
277 | def __init__(self, start_response, wsgi, resolve):
278 | self.real_start_response = start_response
279 |
280 | self.wsgi = wsgi
281 | self.resolve = resolve
282 |
283 | def convert_environ(self, environ):
284 | self.environ = environ
285 |
286 | full_uri = self.environ['REQUEST_URI']
287 |
288 | parts = urlsplit(full_uri)
289 |
290 | self.resolve(full_uri, self.environ, parts.netloc.split(':')[0])
291 |
292 | for header in list(self.environ.keys()):
293 | if header in self.FILTER_REQ_HEADERS:
294 | self.environ.pop(header, '')
295 |
296 | def start_response(self, statusline, headers, exc_info=None):
297 | headers.append(self.PROXY_CONN_CLOSE)
298 |
299 | return self.real_start_response(statusline, headers, exc_info)
300 |
301 | def __call__(self, environ):
302 | self.convert_environ(environ)
303 | return self.wsgi(self.environ, self.start_response)
304 |
305 |
306 | # ============================================================================
307 | class WSGIProxMiddleware(object):
308 | DEFAULT_HOST = 'wsgiprox'
309 |
310 | CA_ROOT_NAME = 'wsgiprox https proxy CA'
311 |
312 | CA_ROOT_FILE = os.path.join('.', 'ca', 'wsgiprox-ca.pem')
313 |
314 | SSL_BASIC_OPTIONS = (
315 | SSL.OP_CIPHER_SERVER_PREFERENCE
316 | )
317 |
318 | SSL_DEFAULT_METHOD = SSL.SSLv23_METHOD
319 | SSL_DEFAULT_OPTIONS = (
320 | SSL.OP_NO_TICKET |
321 | SSL.OP_NO_SSLv2 |
322 | SSL.OP_NO_SSLv3 |
323 | SSL_BASIC_OPTIONS
324 | )
325 |
326 | CONNECT_RESPONSE_1_1 = b'HTTP/1.1 200 Connection Established\r\n\r\n'
327 |
328 | CONNECT_RESPONSE_1_0 = b'HTTP/1.0 200 Connection Established\r\n\r\n'
329 |
330 | DEFAULT_MAX_TUNNELS = 50
331 |
332 | @classmethod
333 | def set_connection_class(cls):
334 | try:
335 | import gevent.socket
336 | assert(gevent.socket.socket == socket.socket)
337 | from wsgiprox.gevent_ssl import SSLConnection as SSLConnection
338 | cls.is_gevent_ssl = True
339 | except Exception as e: #pragma: no cover
340 | logger.debug(str(e))
341 | from OpenSSL.SSL import Connection as SSLConnection
342 | cls.is_gevent_ssl = False
343 | finally:
344 | cls.SSLConnection = SSLConnection
345 |
346 | def __init__(self, wsgi,
347 | prefix_resolver=None,
348 | download_host=None,
349 | proxy_host=None,
350 | proxy_options=None,
351 | proxy_apps=None):
352 |
353 | self._wsgi = wsgi
354 | self.set_connection_class()
355 |
356 | if isinstance(prefix_resolver, str):
357 | prefix_resolver = FixedResolver(prefix_resolver)
358 |
359 | self.prefix_resolver = prefix_resolver or FixedResolver()
360 |
361 | self.proxy_apps = proxy_apps or {}
362 |
363 | self.proxy_host = proxy_host or self.DEFAULT_HOST
364 |
365 | if self.proxy_host not in self.proxy_apps:
366 | self.proxy_apps[self.proxy_host] = None
367 |
368 | # HTTPS Only Options
369 | proxy_options = proxy_options or {}
370 |
371 | ca_name = proxy_options.get('ca_name', self.CA_ROOT_NAME)
372 |
373 | ca_file_cache = proxy_options.get('ca_file_cache', self.CA_ROOT_FILE)
374 |
375 | self.ca = CertificateAuthority(ca_name=ca_name,
376 | ca_file_cache=ca_file_cache,
377 | cert_cache=None,
378 | cert_not_before=-3600)
379 |
380 | self.keepalive_max = proxy_options.get('keepalive_max', self.DEFAULT_MAX_TUNNELS)
381 | self.keepalive_opts = hasattr(socket, 'TCP_KEEPIDLE')
382 |
383 | self._tcp_keepidle = proxy_options.get('tcp_keepidle', 60)
384 | self._tcp_keepintvl = proxy_options.get('tcp_keepintvl', 5)
385 | self._tcp_keepcnt = proxy_options.get('tcp_keepcnt', 3)
386 |
387 | self.num_open_tunnels = 0
388 |
389 | try:
390 | self.root_ca_file = self.ca.get_root_pem_filename()
391 | except Exception as e:
392 | self.root_ca_file = None
393 |
394 | self.use_wildcard = proxy_options.get('use_wildcard_certs', True)
395 |
396 | if proxy_options.get('enable_cert_download', True):
397 | download_host = download_host or self.DEFAULT_HOST
398 | self.proxy_apps[download_host] = CertDownloader(self.ca)
399 |
400 | self.enable_ws = proxy_options.get('enable_websockets', True)
401 | if WebSocketHandler == object:
402 | self.enable_ws = None
403 |
404 | def wsgi(self, env, start_response):
405 | # see if the host matches one of the proxy app hosts
406 | # if so, try to see if there is an wsgi app set
407 | # and if it returns something
408 | hostname = env.get('wsgiprox.matched_proxy_host')
409 | if hostname:
410 | app = self.proxy_apps.get(hostname)
411 | if app:
412 | res = app(env, start_response)
413 | if res is not None:
414 | return res
415 |
416 | # call upstream wsgi app
417 | return self._wsgi(env, start_response)
418 |
419 | def __call__(self, env, start_response):
420 | if env['REQUEST_METHOD'] == 'CONNECT':
421 | return self.handle_connect(env, start_response)
422 | else:
423 | self.ensure_request_uri(env)
424 |
425 | if env['REQUEST_URI'].startswith('http://'):
426 | return self.handle_http_proxy(env, start_response)
427 | else:
428 | return self.wsgi(env, start_response)
429 |
430 | def handle_http_proxy(self, env, start_response):
431 | res = self.require_auth(env, start_response)
432 | if res is not None:
433 | return res
434 |
435 | handler = HttpProxyHandler(start_response, self.wsgi, self.resolve)
436 | return handler(env)
437 |
438 | def handle_connect(self, env, start_response):
439 | raw_sock = self.get_raw_socket(env)
440 | if not raw_sock:
441 | start_response('405 HTTPS Proxy Not Supported',
442 | [('Content-Length', '0')])
443 | return []
444 |
445 | res = self.require_auth(env, start_response)
446 | if res is not None:
447 | return res
448 |
449 | connect_handler = None
450 | curr_sock = None
451 |
452 | try:
453 | scheme, curr_sock = self.wrap_socket(env, raw_sock)
454 |
455 | connect_handler = ConnectHandler(curr_sock, scheme,
456 | self.wsgi, self.resolve)
457 |
458 | self.num_open_tunnels += 1
459 |
460 | connect_handler(env, self.enable_ws)
461 |
462 | while self.keep_alive(connect_handler):
463 | connect_handler(env, self.enable_ws)
464 |
465 | except Exception as e:
466 | logger.debug(str(e))
467 | start_response('500 Unexpected Error',
468 | [('Content-Length', '0')])
469 |
470 |
471 | finally:
472 | if connect_handler:
473 | self.num_open_tunnels -= 1
474 | connect_handler.close()
475 |
476 | if curr_sock and curr_sock != raw_sock:
477 | # this seems to necessary to avoid tls data read later
478 | # in the same gevent
479 | try:
480 | if self.is_gevent_ssl:
481 | curr_sock.recv(0)
482 |
483 | curr_sock.shutdown()
484 |
485 | except:
486 | pass
487 |
488 | finally:
489 | curr_sock.close()
490 |
491 | start_response('200 OK', [])
492 |
493 | return []
494 |
495 | def keep_alive(self, connect_handler):
496 | # keepalive disabled
497 | if self.keepalive_max < 0:
498 | return False
499 |
500 | if not connect_handler.is_keepalive:
501 | return False
502 |
503 | # no max
504 | if self.keepalive_max == 0:
505 | return True
506 |
507 | return (self.num_open_tunnels <= self.keepalive_max)
508 |
509 | def _new_context(self):
510 | context = SSL.Context(self.SSL_DEFAULT_METHOD)
511 | context.set_options(self.SSL_DEFAULT_OPTIONS)
512 | context.set_session_cache_mode(SSL.SESS_CACHE_OFF)
513 | return context
514 |
515 | def create_ssl_context(self, hostname):
516 | cert, key = self.ca.load_cert(hostname,
517 | wildcard=self.use_wildcard,
518 | wildcard_use_parent=True)
519 |
520 | context = self._new_context()
521 | context.use_privatekey(key)
522 | context.use_certificate(cert)
523 | return context
524 |
525 | def _get_connect_response(self, env):
526 | if env.get('SERVER_PROTOCOL', 'HTTP/1.0') == 'HTTP/1.1':
527 | return self.CONNECT_RESPONSE_1_1
528 | else:
529 | return self.CONNECT_RESPONSE_1_0
530 |
531 | def wrap_socket(self, env, sock):
532 | if self.keepalive_max >= 0:
533 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
534 |
535 | if self.keepalive_opts: #pragma: no cover
536 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, self._tcp_keepidle)
537 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, self._tcp_keepintvl)
538 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, self._tcp_keepcnt)
539 |
540 | host_port = env['PATH_INFO']
541 | hostname, port = host_port.split(':', 1)
542 | env['wsgiprox.connect_host'] = hostname
543 |
544 | sock.sendall(self._get_connect_response(env))
545 |
546 | if port == '80':
547 | return 'http', sock
548 |
549 | if port != '443':
550 | env['wsgiprox.connect_port'] = port
551 | peek_buff = sock.recv(16, socket.MSG_PEEK)
552 | # http websocket traffic would start with a GET
553 | if peek_buff.startswith(b'GET '):
554 | return 'http', sock
555 |
556 | def sni_callback(connection):
557 | sni_hostname = connection.get_servername()
558 |
559 | # curl -k (unverified) mode results in empty hostname here
560 | # requests unverified mode still includes an sni hostname
561 | if not sni_hostname:
562 | return
563 |
564 | if six.PY3:
565 | sni_hostname = sni_hostname.decode('iso-8859-1')
566 |
567 | # if same host as CONNECT header, then just keep current context
568 | if sni_hostname == hostname:
569 | return
570 |
571 | connection.set_context(self.create_ssl_context(sni_hostname))
572 | env['wsgiprox.connect_host'] = sni_hostname
573 |
574 | context = self.create_ssl_context(hostname)
575 | context.set_tlsext_servername_callback(sni_callback)
576 |
577 | ssl_sock = self.SSLConnection(context, sock)
578 | ssl_sock.set_accept_state()
579 | ssl_sock.do_handshake()
580 |
581 | return 'https', ssl_sock
582 |
583 | def require_auth(self, env, start_response):
584 | if not hasattr(self.prefix_resolver, 'require_auth'):
585 | return
586 |
587 | auth_req = self.prefix_resolver.require_auth(env)
588 |
589 | if not auth_req:
590 | return
591 |
592 | auth_req = 'Basic realm="{0}"'.format(auth_req)
593 | headers = [('Proxy-Authenticate', auth_req),
594 | ('Proxy-Connection', 'close'),
595 | ('Content-Length', '0')]
596 |
597 | start_response('407 Proxy Authentication', headers)
598 | return []
599 |
600 | def resolve(self, url, env, hostname):
601 | if hostname in self.proxy_apps.keys():
602 | parts = urlsplit(url)
603 | full = parts.path
604 | if parts.query:
605 | full += '?' + parts.query
606 |
607 | env['REQUEST_URI'] = full
608 | env['wsgiprox.matched_proxy_host'] = hostname
609 | env['wsgiprox.proxy_host'] = hostname
610 | else:
611 | env['REQUEST_URI'] = self.prefix_resolver(url, env)
612 | env['wsgiprox.proxy_host'] = self.proxy_host
613 |
614 | queryparts = env['REQUEST_URI'].split('?', 1)
615 |
616 | env['PATH_INFO'] = queryparts[0]
617 |
618 | env['QUERY_STRING'] = queryparts[1] if len(queryparts) > 1 else ''
619 |
620 | def ensure_request_uri(self, env):
621 | if 'REQUEST_URI' in env:
622 | return
623 |
624 | full_uri = env['PATH_INFO']
625 | if env.get('QUERY_STRING'):
626 | full_uri += '?' + env['QUERY_STRING']
627 |
628 | env['REQUEST_URI'] = full_uri
629 |
630 | @classmethod
631 | def get_raw_socket(cls, env): #pragma: no cover
632 | sock = None
633 |
634 | if env.get('uwsgi.version'):
635 | try:
636 | import uwsgi
637 | fd = uwsgi.connection_fd()
638 | sock = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM)
639 | except Exception as e:
640 | pass
641 | elif env.get('gunicorn.socket'):
642 | sock = env['gunicorn.socket']
643 |
644 | if not sock:
645 | # attempt to find socket from wsgi.input
646 | input_ = env.get('wsgi.input')
647 | if input_:
648 | if hasattr(input_, '_sock'):
649 | raw = input_._sock
650 | sock = socket.socket(_sock=raw)
651 | elif hasattr(input_, 'raw'):
652 | sock = input_.raw._sock
653 | elif hasattr(input_, 'rfile'):
654 | # PY3
655 | if hasattr(input_.rfile, 'raw'):
656 | sock = input_.rfile.raw._sock
657 | # PY2
658 | else:
659 | sock = input_.rfile._sock
660 |
661 | return sock
662 |
663 |
664 | # ============================================================================
665 | class CertDownloader(object):
666 | DL_PEM = '/download/pem'
667 | DL_P12 = '/download/p12'
668 |
669 | def __init__(self, ca):
670 | self.ca = ca
671 |
672 | def __call__(self, env, start_response):
673 | path = env.get('PATH_INFO')
674 |
675 | if path == self.DL_PEM:
676 | buff = self.ca.get_root_pem()
677 |
678 | content_type = 'application/x-x509-ca-cert'
679 |
680 | elif path == self.DL_P12:
681 | buff = self.ca.get_root_PKCS12()
682 |
683 | content_type = 'application/x-pkcs12'
684 |
685 | else:
686 | return None
687 |
688 | headers = [('Content-Length', str(len(buff))),
689 | ('Content-Type', content_type)]
690 |
691 | start_response('200 OK', headers)
692 | return [buff]
693 |
694 |
695 |
--------------------------------------------------------------------------------