├── .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 | --------------------------------------------------------------------------------