├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── docs ├── api.rst ├── conf.py ├── index.rst ├── userguide.rst └── versionhistory.rst ├── examples ├── apache_docker.conf ├── apache_docker.sh ├── asyncio-server.py ├── curio-server.py ├── nginx_docker.conf ├── nginx_docker.sh └── twisted-server.py ├── fcgiproto ├── __init__.py ├── connection.py ├── connection.pyi ├── constants.py ├── events.py ├── exceptions.py ├── records.py └── states.py ├── setup.cfg ├── setup.py ├── tests ├── test_connection.py ├── test_records.py └── test_states.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = fcgiproto 3 | branch = 1 4 | 5 | [report] 6 | show_missing = true 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea/ 4 | .coverage 5 | .cache/ 6 | .tox/ 7 | .eggs/ 8 | *.egg-info/ 9 | *.pyc 10 | dist/ 11 | docs/_build/ 12 | build/ 13 | virtualenv/ 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | 5 | python: 6 | - "2.7" 7 | - "3.3" 8 | - "3.4" 9 | - "3.5" 10 | - "3.6" 11 | 12 | install: pip install tox-travis coveralls 13 | 14 | script: tox 15 | 16 | after_success: coveralls 17 | 18 | deploy: 19 | provider: pypi 20 | user: agronholm 21 | password: 22 | secure: irLJvcMUgiM0Q+0mXEze2JtIJEloFdehvICmYevGMmPm/01rR//iM+WFjya754EwLrJwNYULhxAQU71ysVTf47cNu7QGL/jmQcGIpXzBNm2KOfhFiWASN8srPXHkiUn98OpQypuU64zvUiAYPYz/73G6x1qHYd2lGGfAeunXIzliUlB4f9Vap7U9LiUmRVltIVbWqT0oVFZZItsVT5pBiGBWOhTBjlZ2P3aR71eJ7S3PFYs/wDAzsobeaY7AmG7D+2eOgTt+tb7zyhQXTMD+5o2UOCJfzxxUGEf804zPQzIjuKD8XJX0TBTuKsPlB8NNt3Kwqc2KL2vEglpMoO7MtlqT8DJeecxLr72zoos5Z0KEejwgPyQAUy2ICV36t73Cu+HPnDA9LJC847PmdkU/JDpRnIqpaALorNzBKiCB6H8bly3PcQjaoLiTqhfcXnvvuk3lqWBbt4eIThdshndD5TE2oYfZhK6t4p50CwOxLpxM4fTqh7H9KiGPIjPzjFyb6nuqSCEPcv8PFHtQ0Rn9QprVyny+iW/BRtDahM18+htoBnkjpiUAwIOuBmNZpPEmXQ7w6XU7LZ/fWzGvwbgBPGuvwSxh2AtmrNFSQPSDZK73lwX6rKtEmlSaWWUPAk4tIZbFk7ZGC6U9hv28OhV/bm7ZPc4sVCViDN1qKT9EIsQ= 23 | distributions: sdist bdist_wheel 24 | on: 25 | tags: true 26 | python: "3.6" 27 | repo: agronholm/fcgiproto 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is the MIT license: http://www.opensource.org/licenses/mit-license.php 2 | 3 | Copyright (c) Alex Grönholm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 9 | to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or 12 | substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 16 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/agronholm/fcgiproto.svg?branch=master 2 | :target: https://travis-ci.org/agronholm/fcgiproto 3 | :alt: Build Status 4 | .. image:: https://coveralls.io/repos/github/agronholm/fcgiproto/badge.svg?branch=master 5 | :target: https://coveralls.io/github/agronholm/fcgiproto?branch=master 6 | :alt: Code Coverage 7 | 8 | The FastCGI_ protocol is a protocol commonly used to relay HTTP requests and responses between a 9 | front-end web server (nginx, Apache, etc.) and a back-end web application. 10 | 11 | This library implements this protocol for the web application end as a pure state-machine which 12 | only takes in bytes and returns a list of parsed events. This leaves users free to use any I/O 13 | approach they see fit (asyncio_, curio_, Twisted_, etc.). Sample code is provided for implementing 14 | a FastCGI server using a variety of I/O frameworks. 15 | 16 | .. _FastCGI: https://htmlpreview.github.io/?https://github.com/FastCGI-Archives/FastCGI.com/blob/master/docs/FastCGI%20Specification.html 17 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 18 | .. _curio: https://github.com/dabeaz/curio 19 | .. _Twisted: https://twistedmatrix.com/ 20 | 21 | Project links 22 | ------------- 23 | 24 | * `Documentation `_ 25 | * `Source code `_ 26 | * `Issue tracker `_ 27 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | Classes 5 | ------- 6 | 7 | .. autoclass:: fcgiproto.FastCGIConnection 8 | :members: 9 | 10 | .. autoclass:: fcgiproto.RequestEvent 11 | :members: 12 | 13 | .. autoclass:: fcgiproto.RequestBeginEvent 14 | :members: 15 | 16 | .. autoclass:: fcgiproto.RequestAbortEvent 17 | :members: 18 | 19 | .. autoclass:: fcgiproto.RequestDataEvent 20 | :members: 21 | 22 | .. autoclass:: fcgiproto.RequestSecondaryDataEvent 23 | :members: 24 | 25 | .. autoexception:: fcgiproto.ProtocolError 26 | 27 | Constants 28 | --------- 29 | 30 | * ``fcgiproto.FCGI_RESPONDER`` 31 | * ``fcgiproto.FCGI_AUTHORIZER`` 32 | * ``fcgiproto.FCGI_FILTER`` 33 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | import pkg_resources 4 | 5 | extensions = [ 6 | 'sphinx.ext.autodoc', 7 | ] 8 | 9 | templates_path = ['_templates'] 10 | source_suffix = '.rst' 11 | master_doc = 'index' 12 | project = 'fcgiproto' 13 | author = u'Alex Grönholm' 14 | copyright = '2016, ' + author 15 | 16 | v = pkg_resources.get_distribution('fcgiproto').parsed_version 17 | version = v.base_version 18 | release = v.public 19 | 20 | language = None 21 | 22 | exclude_patterns = ['_build'] 23 | pygments_style = 'sphinx' 24 | todo_include_todos = False 25 | 26 | html_theme = 'classic' 27 | html_static_path = ['_static'] 28 | htmlhelp_basename = 'fcgiprotodoc' 29 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | FastCGI state-machine protocol (fcgiproto) 2 | ========================================== 3 | 4 | .. include:: ../README.rst 5 | :start-line: 7 6 | :end-before: Project links 7 | 8 | 9 | Table of Contents 10 | ================= 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | userguide 16 | api 17 | versionhistory 18 | -------------------------------------------------------------------------------- /docs/userguide.rst: -------------------------------------------------------------------------------- 1 | Protocol implementor's guide 2 | ============================ 3 | 4 | Creating a real-world implementation of FastCGI using fcgiproto is quite straightforward. 5 | As with other sans-io protocols, you feed incoming data to fcgiproto and it vends events in return. 6 | To invoke actions on the connection, just call its methods, like 7 | :meth:`~fcgiproto.FastCGIConnection.send_headers` and so on. 8 | To get pending outgoing data, use the :meth:`~fcgiproto.FastCGIConnection.data_to_send` method. 9 | 10 | Connection configuration 11 | ------------------------ 12 | 13 | The most common role is the responder role (``FCGI_RESPONDER``). The authorizer 14 | (``FCGI_AUTHORIZER``) and filter (``FCGI_FILTER``) roles are not commonly supported by web server 15 | software. As such, you will want to leave the default role setting alone, unless you really know 16 | what you're doing. 17 | 18 | It's also possible to set FCGI management values. The FastCGI specification defines names of three 19 | values: 20 | 21 | * ``FCGI_MAX_CONNS``: The maximum number of concurrent transport connections this application will 22 | accept, e.g. ``1`` or ``10`` 23 | * ``FCGI_MAX_REQS``: The maximum number of concurrent requests this application will accept, e.g. 24 | ``1`` or ``50``. 25 | * ``FCGI_MPXS_CONNS``: ``0`` if this application does not multiplex connections (i.e. handle 26 | concurrent requests over each connection), ``1`` otherwise. 27 | 28 | The connection sets ``FCGI_MPXS_CONNS`` to ``1`` by default. It should be noted that the web server 29 | may never even query for these values, so leave this setting alone unless you know you need it. 30 | At least nginx does not attempt to multiplex FCGI connections, nor does it query for any management 31 | values. 32 | 33 | Implementor's responsibilities 34 | ------------------------------ 35 | 36 | The logic in :class:`~fcgiproto.FastCGIConnection` will handle most complications of the protocol. 37 | That leaves just a handful of things for I/O implementors to keep in mind: 38 | 39 | * Always get any outgoing data from the connection (using 40 | :meth:`~fcgiproto.FastCGIConnection.data_to_send`) after calling either 41 | :meth:`~fcgiproto.FastCGIConnection.feed_data` or any of the other methods, and send it to the 42 | remote host 43 | * Remember to set ``Content-Length`` if your response contains a body 44 | * Respect the ``keep_connection`` flag in :class:`~fcgiproto.RequestBeginEvent`. 45 | Close the connection after calling :meth:`~fcgiproto.FastCGIConnection.end_request` if the flag 46 | is ``False``. 47 | 48 | Handling requests 49 | ----------------- 50 | 51 | **RESPONDER** 52 | 53 | The sequence for handling responder requests (the most common case) is as follows: 54 | 55 | #. a :class:`~fcgiproto.RequestBeginEvent` is received 56 | #. one or more :class:`~fcgiproto.RequestDataEvent` are received, the last one having an empty 57 | bytestring as ``data`` attribute 58 | #. the application calls :meth:`~fcgiproto.FastCGIConnection.send_headers` once 59 | #. the application calls :meth:`~fcgiproto.FastCGIConnection.send_data` one or more times 60 | and the last call must have ``end_request`` set to ``True`` 61 | 62 | The implementor can decide whether to wait until all of the request body has been received, or 63 | start running the request handler code right after :class:`~fcgiproto.RequestBeginEvent` has been 64 | received (to facilitate streaming uploads for example). 65 | 66 | In FastCGI responses, the HTTP status code is sent using the ``Status`` header. As a convenience, 67 | the :meth:`~fcgiproto.FastCGIConnection.send_headers` method provides the ``status`` parameter 68 | to add this header. 69 | 70 | **AUTHORIZER** 71 | 72 | Authorizer requests differ from responder requests in the way that the application never receives 73 | any request body. They also don't receive the ``CONTENT_LENGTH``, ``PATH_INFO``, ``SCRIPT_NAME`` or 74 | ``PATH_TRANSLATED`` parameters, which severely limits the usefulness of this role. 75 | 76 | The request-response sequence for authorizers goes as follows: 77 | 78 | #. a :class:`~fcgiproto.RequestBeginEvent` is received 79 | #. the application calls :meth:`~fcgiproto.FastCGIConnection.send_headers` once 80 | #. the application calls :meth:`~fcgiproto.FastCGIConnection.send_data` one or more times 81 | and the last call must have ``end_request`` set to ``True`` 82 | 83 | A response code other than ``200`` will be interpreted as a negative response. 84 | 85 | **FILTER** 86 | 87 | Filter applications receive all the same information as responders, but they are also sent a 88 | secondary data stream which they're supposed to filter. 89 | 90 | The request-response sequence for filters goes as follows: 91 | 92 | #. a :class:`~fcgiproto.RequestBeginEvent` is received 93 | #. one or more :class:`~fcgiproto.RequestDataEvent` are received, the last one having an empty 94 | bytestring as ``data`` attribute 95 | #. one or more :class:`~fcgiproto.RequestSecondaryDataEvent` are received, the last one having an 96 | empty bytestring as ``data`` attribute 97 | #. the application calls :meth:`~fcgiproto.FastCGIConnection.send_headers` once 98 | #. the application calls :meth:`~fcgiproto.FastCGIConnection.send_data` one or more times 99 | and the last call must have ``end_request`` set to ``True`` 100 | 101 | The application is expected to send the (modified) secondary data stream as the response body. 102 | It must read in all of the request body before starting to send a response (thus somewhat deviating 103 | from the sequence above), but it does not need to wait for the secondary data stream to end (for 104 | example if the response comes from a cache). 105 | 106 | Handling request aborts 107 | ----------------------- 108 | 109 | If the application receives a :class:`~fcgiproto.RequestAbortEvent`, it should cease processing of 110 | the request at once. No headers or data should be sent from this point on for this request, and 111 | :meth:`~fcgiproto.FastCGIConnection.end_request` should be called as soon as possible. 112 | 113 | Running the examples 114 | -------------------- 115 | 116 | The ``examples`` directory in the project source tree contains example code for several popular 117 | I/O frameworks to get you started. Just run any of the server scripts and it will start a FastCGI 118 | server listening on port 9500. 119 | 120 | Since FastCGI requires a front-end server, a Docker script and configuration files for both nginx 121 | and Apache HTTPd have been provided as a convenience. Just run either ``nginx_docker.sh`` or 122 | ``apache_docker.sh`` from the ``examples`` directory and navigate to http://127.0.0.1/ to see the 123 | result. The example code displays a web page that shows the FastCGI parameters and the request body 124 | (if any). 125 | 126 | .. note:: You may have to make adjustments to the configuration if your Docker interface address or 127 | desired host HTTP port don't match the provided configuration. 128 | -------------------------------------------------------------------------------- /docs/versionhistory.rst: -------------------------------------------------------------------------------- 1 | Version history 2 | =============== 3 | 4 | This library adheres to `Semantic Versioning `_. 5 | 6 | **1.0.2** (2016-10-25) 7 | 8 | - Fixed setup.py to include package data (``.pyi`` files) when installing 9 | 10 | **1.0.1** (2016-10-23) 11 | 12 | - Added an alternative, Apache based docker configuration in ``examples/`` 13 | - Changed response headers to be emitted with a space after ``:`` and with 14 | lines ending with CRLF instead of plain LF 15 | 16 | **1.0.0** (2016-09-04) 17 | 18 | - Initial release 19 | -------------------------------------------------------------------------------- /examples/apache_docker.conf: -------------------------------------------------------------------------------- 1 | # 2 | # This is the main Apache HTTP server configuration file. It contains the 3 | # configuration directives that give the server its instructions. 4 | # See for detailed information. 5 | # In particular, see 6 | # 7 | # for a discussion of each configuration directive. 8 | # 9 | # Do NOT simply read the instructions in here without understanding 10 | # what they do. They're here only as hints or reminders. If you are unsure 11 | # consult the online docs. You have been warned. 12 | # 13 | # Configuration and logfile names: If the filenames you specify for many 14 | # of the server's control files begin with "/" (or "drive:/" for Win32), the 15 | # server will use that explicit path. If the filenames do *not* begin 16 | # with "/", the value of ServerRoot is prepended -- so "logs/access_log" 17 | # with ServerRoot set to "/usr/local/apache2" will be interpreted by the 18 | # server as "/usr/local/apache2/logs/access_log", whereas "/logs/access_log" 19 | # will be interpreted as '/logs/access_log'. 20 | 21 | # 22 | # ServerRoot: The top of the directory tree under which the server's 23 | # configuration, error, and log files are kept. 24 | # 25 | # Do not add a slash at the end of the directory path. If you point 26 | # ServerRoot at a non-local disk, be sure to specify a local disk on the 27 | # Mutex directive, if file-based mutexes are used. If you wish to share the 28 | # same ServerRoot for multiple httpd daemons, you will need to change at 29 | # least PidFile. 30 | # 31 | ServerRoot "/usr/local/apache2" 32 | 33 | # 34 | # Mutex: Allows you to set the mutex mechanism and mutex file directory 35 | # for individual mutexes, or change the global defaults 36 | # 37 | # Uncomment and change the directory if mutexes are file-based and the default 38 | # mutex file directory is not on a local disk or is not appropriate for some 39 | # other reason. 40 | # 41 | # Mutex default:logs 42 | 43 | # 44 | # Listen: Allows you to bind Apache to specific IP addresses and/or 45 | # ports, instead of the default. See also the 46 | # directive. 47 | # 48 | # Change this to Listen on specific IP addresses as shown below to 49 | # prevent Apache from glomming onto all bound IP addresses. 50 | # 51 | #Listen 12.34.56.78:80 52 | Listen 80 53 | 54 | # 55 | # Dynamic Shared Object (DSO) Support 56 | # 57 | # To be able to use the functionality of a module which was built as a DSO you 58 | # have to place corresponding `LoadModule' lines at this location so the 59 | # directives contained in it are actually available _before_ they are used. 60 | # Statically compiled modules (those listed by `httpd -l') do not need 61 | # to be loaded here. 62 | # 63 | # Example: 64 | # LoadModule foo_module modules/mod_foo.so 65 | # 66 | LoadModule authn_file_module modules/mod_authn_file.so 67 | #LoadModule authn_dbm_module modules/mod_authn_dbm.so 68 | #LoadModule authn_anon_module modules/mod_authn_anon.so 69 | #LoadModule authn_dbd_module modules/mod_authn_dbd.so 70 | #LoadModule authn_socache_module modules/mod_authn_socache.so 71 | LoadModule authn_core_module modules/mod_authn_core.so 72 | LoadModule authz_host_module modules/mod_authz_host.so 73 | LoadModule authz_groupfile_module modules/mod_authz_groupfile.so 74 | LoadModule authz_user_module modules/mod_authz_user.so 75 | #LoadModule authz_dbm_module modules/mod_authz_dbm.so 76 | #LoadModule authz_owner_module modules/mod_authz_owner.so 77 | #LoadModule authz_dbd_module modules/mod_authz_dbd.so 78 | LoadModule authz_core_module modules/mod_authz_core.so 79 | #LoadModule authnz_ldap_module modules/mod_authnz_ldap.so 80 | #LoadModule authnz_fcgi_module modules/mod_authnz_fcgi.so 81 | LoadModule access_compat_module modules/mod_access_compat.so 82 | LoadModule auth_basic_module modules/mod_auth_basic.so 83 | #LoadModule auth_form_module modules/mod_auth_form.so 84 | #LoadModule auth_digest_module modules/mod_auth_digest.so 85 | #LoadModule allowmethods_module modules/mod_allowmethods.so 86 | #LoadModule isapi_module modules/mod_isapi.so 87 | #LoadModule file_cache_module modules/mod_file_cache.so 88 | #LoadModule cache_module modules/mod_cache.so 89 | #LoadModule cache_disk_module modules/mod_cache_disk.so 90 | #LoadModule cache_socache_module modules/mod_cache_socache.so 91 | #LoadModule socache_shmcb_module modules/mod_socache_shmcb.so 92 | #LoadModule socache_dbm_module modules/mod_socache_dbm.so 93 | #LoadModule socache_memcache_module modules/mod_socache_memcache.so 94 | #LoadModule watchdog_module modules/mod_watchdog.so 95 | #LoadModule macro_module modules/mod_macro.so 96 | #LoadModule dbd_module modules/mod_dbd.so 97 | #LoadModule bucketeer_module modules/mod_bucketeer.so 98 | #LoadModule dumpio_module modules/mod_dumpio.so 99 | #LoadModule echo_module modules/mod_echo.so 100 | #LoadModule example_hooks_module modules/mod_example_hooks.so 101 | #LoadModule case_filter_module modules/mod_case_filter.so 102 | #LoadModule case_filter_in_module modules/mod_case_filter_in.so 103 | #LoadModule example_ipc_module modules/mod_example_ipc.so 104 | #LoadModule buffer_module modules/mod_buffer.so 105 | #LoadModule data_module modules/mod_data.so 106 | #LoadModule ratelimit_module modules/mod_ratelimit.so 107 | LoadModule reqtimeout_module modules/mod_reqtimeout.so 108 | #LoadModule ext_filter_module modules/mod_ext_filter.so 109 | #LoadModule request_module modules/mod_request.so 110 | #LoadModule include_module modules/mod_include.so 111 | LoadModule filter_module modules/mod_filter.so 112 | #LoadModule reflector_module modules/mod_reflector.so 113 | #LoadModule substitute_module modules/mod_substitute.so 114 | #LoadModule sed_module modules/mod_sed.so 115 | #LoadModule charset_lite_module modules/mod_charset_lite.so 116 | #LoadModule deflate_module modules/mod_deflate.so 117 | LoadModule mime_module modules/mod_mime.so 118 | #LoadModule ldap_module modules/mod_ldap.so 119 | LoadModule log_config_module modules/mod_log_config.so 120 | #LoadModule log_debug_module modules/mod_log_debug.so 121 | #LoadModule log_forensic_module modules/mod_log_forensic.so 122 | #LoadModule logio_module modules/mod_logio.so 123 | LoadModule env_module modules/mod_env.so 124 | #LoadModule mime_magic_module modules/mod_mime_magic.so 125 | #LoadModule cern_meta_module modules/mod_cern_meta.so 126 | #LoadModule expires_module modules/mod_expires.so 127 | LoadModule headers_module modules/mod_headers.so 128 | #LoadModule ident_module modules/mod_ident.so 129 | #LoadModule usertrack_module modules/mod_usertrack.so 130 | #LoadModule unique_id_module modules/mod_unique_id.so 131 | LoadModule setenvif_module modules/mod_setenvif.so 132 | LoadModule version_module modules/mod_version.so 133 | #LoadModule remoteip_module modules/mod_remoteip.so 134 | LoadModule proxy_module modules/mod_proxy.so 135 | #LoadModule proxy_connect_module modules/mod_proxy_connect.so 136 | #LoadModule proxy_ftp_module modules/mod_proxy_ftp.so 137 | #LoadModule proxy_http_module modules/mod_proxy_http.so 138 | LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so 139 | #LoadModule proxy_scgi_module modules/mod_proxy_scgi.so 140 | #LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so 141 | #LoadModule proxy_ajp_module modules/mod_proxy_ajp.so 142 | #LoadModule proxy_balancer_module modules/mod_proxy_balancer.so 143 | #LoadModule proxy_express_module modules/mod_proxy_express.so 144 | #LoadModule proxy_hcheck_module modules/mod_proxy_hcheck.so 145 | #LoadModule session_module modules/mod_session.so 146 | #LoadModule session_cookie_module modules/mod_session_cookie.so 147 | #LoadModule session_dbd_module modules/mod_session_dbd.so 148 | #LoadModule slotmem_shm_module modules/mod_slotmem_shm.so 149 | #LoadModule slotmem_plain_module modules/mod_slotmem_plain.so 150 | #LoadModule ssl_module modules/mod_ssl.so 151 | #LoadModule optional_hook_export_module modules/mod_optional_hook_export.so 152 | #LoadModule optional_hook_import_module modules/mod_optional_hook_import.so 153 | #LoadModule optional_fn_import_module modules/mod_optional_fn_import.so 154 | #LoadModule optional_fn_export_module modules/mod_optional_fn_export.so 155 | #LoadModule dialup_module modules/mod_dialup.so 156 | #LoadModule lbmethod_byrequests_module modules/mod_lbmethod_byrequests.so 157 | #LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so 158 | #LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so 159 | #LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so 160 | LoadModule unixd_module modules/mod_unixd.so 161 | #LoadModule heartbeat_module modules/mod_heartbeat.so 162 | #LoadModule heartmonitor_module modules/mod_heartmonitor.so 163 | #LoadModule dav_module modules/mod_dav.so 164 | LoadModule status_module modules/mod_status.so 165 | LoadModule autoindex_module modules/mod_autoindex.so 166 | #LoadModule asis_module modules/mod_asis.so 167 | #LoadModule info_module modules/mod_info.so 168 | #LoadModule suexec_module modules/mod_suexec.so 169 | 170 | #LoadModule cgid_module modules/mod_cgid.so 171 | 172 | 173 | #LoadModule cgi_module modules/mod_cgi.so 174 | 175 | #LoadModule dav_fs_module modules/mod_dav_fs.so 176 | #LoadModule dav_lock_module modules/mod_dav_lock.so 177 | #LoadModule vhost_alias_module modules/mod_vhost_alias.so 178 | #LoadModule negotiation_module modules/mod_negotiation.so 179 | LoadModule dir_module modules/mod_dir.so 180 | #LoadModule imagemap_module modules/mod_imagemap.so 181 | #LoadModule actions_module modules/mod_actions.so 182 | #LoadModule speling_module modules/mod_speling.so 183 | #LoadModule userdir_module modules/mod_userdir.so 184 | LoadModule alias_module modules/mod_alias.so 185 | #LoadModule rewrite_module modules/mod_rewrite.so 186 | 187 | 188 | # 189 | # If you wish httpd to run as a different user or group, you must run 190 | # httpd as root initially and it will switch. 191 | # 192 | # User/Group: The name (or #number) of the user/group to run httpd as. 193 | # It is usually good practice to create a dedicated user and group for 194 | # running httpd, as with most system services. 195 | # 196 | User daemon 197 | Group daemon 198 | 199 | 200 | 201 | # 'Main' server configuration 202 | # 203 | # The directives in this section set up the values used by the 'main' 204 | # server, which responds to any requests that aren't handled by a 205 | # definition. These values also provide defaults for 206 | # any containers you may define later in the file. 207 | # 208 | # All of these directives may appear inside containers, 209 | # in which case these default settings will be overridden for the 210 | # virtual host being defined. 211 | # 212 | 213 | # 214 | # ServerAdmin: Your address, where problems with the server should be 215 | # e-mailed. This address appears on some server-generated pages, such 216 | # as error documents. e.g. admin@your-domain.com 217 | # 218 | ServerAdmin you@example.com 219 | 220 | # 221 | # ServerName gives the name and port that the server uses to identify itself. 222 | # This can often be determined automatically, but we recommend you specify 223 | # it explicitly to prevent problems during startup. 224 | # 225 | # If your host doesn't have a registered DNS name, enter its IP address here. 226 | # 227 | #ServerName www.example.com:80 228 | 229 | # 230 | # Deny access to the entirety of your server's filesystem. You must 231 | # explicitly permit access to web content directories in other 232 | # blocks below. 233 | # 234 | 235 | AllowOverride none 236 | Require all denied 237 | 238 | 239 | # 240 | # Note that from this point forward you must specifically allow 241 | # particular features to be enabled - so if something's not working as 242 | # you might expect, make sure that you have specifically enabled it 243 | # below. 244 | # 245 | 246 | # 247 | # DocumentRoot: The directory out of which you will serve your 248 | # documents. By default, all requests are taken from this directory, but 249 | # symbolic links and aliases may be used to point to other locations. 250 | # 251 | DocumentRoot "/usr/local/apache2/htdocs" 252 | 253 | # 254 | # Possible values for the Options directive are "None", "All", 255 | # or any combination of: 256 | # Indexes Includes FollowSymLinks SymLinksifOwnerMatch ExecCGI MultiViews 257 | # 258 | # Note that "MultiViews" must be named *explicitly* --- "Options All" 259 | # doesn't give it to you. 260 | # 261 | # The Options directive is both complicated and important. Please see 262 | # http://httpd.apache.org/docs/2.4/mod/core.html#options 263 | # for more information. 264 | # 265 | Options Indexes FollowSymLinks 266 | 267 | # 268 | # AllowOverride controls what directives may be placed in .htaccess files. 269 | # It can be "All", "None", or any combination of the keywords: 270 | # AllowOverride FileInfo AuthConfig Limit 271 | # 272 | AllowOverride None 273 | 274 | # 275 | # Controls who can get stuff from this server. 276 | # 277 | Require all granted 278 | 279 | 280 | # 281 | # DirectoryIndex: sets the file that Apache will serve if a directory 282 | # is requested. 283 | # 284 | 285 | DirectoryIndex index.html 286 | 287 | 288 | # 289 | # The following lines prevent .htaccess and .htpasswd files from being 290 | # viewed by Web clients. 291 | # 292 | 293 | Require all denied 294 | 295 | 296 | # 297 | # ErrorLog: The location of the error log file. 298 | # If you do not specify an ErrorLog directive within a 299 | # container, error messages relating to that virtual host will be 300 | # logged here. If you *do* define an error logfile for a 301 | # container, that host's errors will be logged there and not here. 302 | # 303 | ErrorLog /proc/self/fd/2 304 | 305 | # 306 | # LogLevel: Control the number of messages logged to the error_log. 307 | # Possible values include: debug, info, notice, warn, error, crit, 308 | # alert, emerg. 309 | # 310 | LogLevel warn 311 | 312 | 313 | # 314 | # The following directives define some format nicknames for use with 315 | # a CustomLog directive (see below). 316 | # 317 | LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined 318 | LogFormat "%h %l %u %t \"%r\" %>s %b" common 319 | 320 | 321 | # You need to enable mod_logio.c to use %I and %O 322 | LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio 323 | 324 | 325 | # 326 | # The location and format of the access logfile (Common Logfile Format). 327 | # If you do not define any access logfiles within a 328 | # container, they will be logged here. Contrariwise, if you *do* 329 | # define per- access logfiles, transactions will be 330 | # logged therein and *not* in this file. 331 | # 332 | CustomLog /proc/self/fd/1 common 333 | 334 | # 335 | # If you prefer a logfile with access, agent, and referer information 336 | # (Combined Logfile Format) you can use the following directive. 337 | # 338 | #CustomLog "logs/access_log" combined 339 | 340 | 341 | 342 | # 343 | # Redirect: Allows you to tell clients about documents that used to 344 | # exist in your server's namespace, but do not anymore. The client 345 | # will make a new request for the document at its new location. 346 | # Example: 347 | # Redirect permanent /foo http://www.example.com/bar 348 | 349 | # 350 | # Alias: Maps web paths into filesystem paths and is used to 351 | # access content that does not live under the DocumentRoot. 352 | # Example: 353 | # Alias /webpath /full/filesystem/path 354 | # 355 | # If you include a trailing / on /webpath then the server will 356 | # require it to be present in the URL. You will also likely 357 | # need to provide a section to allow access to 358 | # the filesystem path. 359 | 360 | # 361 | # ScriptAlias: This controls which directories contain server scripts. 362 | # ScriptAliases are essentially the same as Aliases, except that 363 | # documents in the target directory are treated as applications and 364 | # run by the server when requested rather than as documents sent to the 365 | # client. The same rules about trailing "/" apply to ScriptAlias 366 | # directives as to Alias. 367 | # 368 | ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/" 369 | 370 | 371 | 372 | 373 | # 374 | # ScriptSock: On threaded servers, designate the path to the UNIX 375 | # socket used to communicate with the CGI daemon of mod_cgid. 376 | # 377 | #Scriptsock cgisock 378 | 379 | 380 | # 381 | # "/usr/local/apache2/cgi-bin" should be changed to whatever your ScriptAliased 382 | # CGI directory exists, if you have that configured. 383 | # 384 | 385 | AllowOverride None 386 | Options None 387 | Require all granted 388 | 389 | 390 | 391 | # 392 | # TypesConfig points to the file containing the list of mappings from 393 | # filename extension to MIME-type. 394 | # 395 | TypesConfig conf/mime.types 396 | 397 | # 398 | # AddType allows you to add to or override the MIME configuration 399 | # file specified in TypesConfig for specific file types. 400 | # 401 | #AddType application/x-gzip .tgz 402 | # 403 | # AddEncoding allows you to have certain browsers uncompress 404 | # information on the fly. Note: Not all browsers support this. 405 | # 406 | #AddEncoding x-compress .Z 407 | #AddEncoding x-gzip .gz .tgz 408 | # 409 | # If the AddEncoding directives above are commented-out, then you 410 | # probably should define those extensions to indicate media types: 411 | # 412 | AddType application/x-compress .Z 413 | AddType application/x-gzip .gz .tgz 414 | 415 | # 416 | # AddHandler allows you to map certain file extensions to "handlers": 417 | # actions unrelated to filetype. These can be either built into the server 418 | # or added with the Action directive (see below) 419 | # 420 | # To use CGI scripts outside of ScriptAliased directories: 421 | # (You will also need to add "ExecCGI" to the "Options" directive.) 422 | # 423 | #AddHandler cgi-script .cgi 424 | 425 | # For type maps (negotiated resources): 426 | #AddHandler type-map var 427 | 428 | # 429 | # Filters allow you to process content before it is sent to the client. 430 | # 431 | # To parse .shtml files for server-side includes (SSI): 432 | # (You will also need to add "Includes" to the "Options" directive.) 433 | # 434 | #AddType text/html .shtml 435 | #AddOutputFilter INCLUDES .shtml 436 | 437 | 438 | # 439 | # The mod_mime_magic module allows the server to use various hints from the 440 | # contents of the file itself to determine its type. The MIMEMagicFile 441 | # directive tells the module where the hint definitions are located. 442 | # 443 | #MIMEMagicFile conf/magic 444 | 445 | # 446 | # Customizable error responses come in three flavors: 447 | # 1) plain text 2) local redirects 3) external redirects 448 | # 449 | # Some examples: 450 | #ErrorDocument 500 "The server made a boo boo." 451 | #ErrorDocument 404 /missing.html 452 | #ErrorDocument 404 "/cgi-bin/missing_handler.pl" 453 | #ErrorDocument 402 http://www.example.com/subscription_info.html 454 | # 455 | 456 | # 457 | # MaxRanges: Maximum number of Ranges in a request before 458 | # returning the entire resource, or one of the special 459 | # values 'default', 'none' or 'unlimited'. 460 | # Default setting is to accept 200 Ranges. 461 | #MaxRanges unlimited 462 | 463 | # 464 | # EnableMMAP and EnableSendfile: On systems that support it, 465 | # memory-mapping or the sendfile syscall may be used to deliver 466 | # files. This usually improves server performance, but must 467 | # be turned off when serving from networked-mounted 468 | # filesystems or if support for these functions is otherwise 469 | # broken on your system. 470 | # Defaults: EnableMMAP On, EnableSendfile Off 471 | # 472 | #EnableMMAP off 473 | #EnableSendfile on 474 | 475 | # Supplemental configuration 476 | # 477 | # The configuration files in the conf/extra/ directory can be 478 | # included to add extra features or to modify the default configuration of 479 | # the server, or you may simply copy their contents here and change as 480 | # necessary. 481 | 482 | # Server-pool management (MPM specific) 483 | #Include conf/extra/httpd-mpm.conf 484 | 485 | # Multi-language error messages 486 | #Include conf/extra/httpd-multilang-errordoc.conf 487 | 488 | # Fancy directory listings 489 | #Include conf/extra/httpd-autoindex.conf 490 | 491 | # Language settings 492 | #Include conf/extra/httpd-languages.conf 493 | 494 | # User home directories 495 | #Include conf/extra/httpd-userdir.conf 496 | 497 | # Real-time info on requests and configuration 498 | #Include conf/extra/httpd-info.conf 499 | 500 | # Virtual hosts 501 | #Include conf/extra/httpd-vhosts.conf 502 | 503 | # Local access to the Apache HTTP Server Manual 504 | #Include conf/extra/httpd-manual.conf 505 | 506 | # Distributed authoring and versioning (WebDAV) 507 | #Include conf/extra/httpd-dav.conf 508 | 509 | # Various default settings 510 | #Include conf/extra/httpd-default.conf 511 | 512 | # Configure mod_proxy_html to understand HTML4/XHTML1 513 | 514 | Include conf/extra/proxy-html.conf 515 | 516 | 517 | # Secure (SSL/TLS) connections 518 | #Include conf/extra/httpd-ssl.conf 519 | # 520 | # Note: The following must must be present to support 521 | # starting without SSL on platforms with no /dev/random equivalent 522 | # but a statically compiled-in mod_ssl. 523 | # 524 | 525 | SSLRandomSeed startup builtin 526 | SSLRandomSeed connect builtin 527 | 528 | 529 | # Enable FCGI example application at / 530 | ProxyPass "/" "fcgi://172.17.0.1:9500/" enablereuse=on 531 | -------------------------------------------------------------------------------- /examples/apache_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run this in the same directory! 4 | docker run --rm -p 80:80 -v $PWD/apache_docker.conf:/usr/local/apache2/conf/httpd.conf:ro httpd:alpine 5 | -------------------------------------------------------------------------------- /examples/asyncio-server.py: -------------------------------------------------------------------------------- 1 | from asyncio import get_event_loop, Protocol 2 | 3 | from fcgiproto import FastCGIConnection, RequestBeginEvent, RequestDataEvent 4 | 5 | 6 | class FastCGIProtocol(Protocol): 7 | def __init__(self): 8 | self.transport = None 9 | self.conn = FastCGIConnection() 10 | self.requests = {} 11 | 12 | def connection_made(self, transport): 13 | self.transport = transport 14 | 15 | def data_received(self, data): 16 | try: 17 | for event in self.conn.feed_data(data): 18 | if isinstance(event, RequestBeginEvent): 19 | self.requests[event.request_id] = ( 20 | event.params, event.keep_connection, bytearray()) 21 | elif isinstance(event, RequestDataEvent): 22 | request_data = self.requests[event.request_id][2] 23 | if event.data: 24 | request_data.extend(event.data) 25 | else: 26 | params, keep_connection, request_data = self.requests.pop(event.request_id) 27 | self.handle_request(event.request_id, params, request_data) 28 | if not keep_connection: 29 | self.transport.close() 30 | 31 | self.transport.write(self.conn.data_to_send()) 32 | except Exception: 33 | self.transport.abort() 34 | raise 35 | 36 | def handle_request(self, request_id, params, content): 37 | fcgi_params = '\n'.join('%s%s' % (key, value) 38 | for key, value in params.items()) 39 | content = content.decode('utf-8', errors='replace') 40 | response = ("""\ 41 | 42 | 43 | 44 |

FCGI parameters

45 | 46 | %s 47 |
48 |

Request body

49 |
%s
50 | 51 | 52 | """ % (fcgi_params, content)).encode('utf-8') 53 | headers = [ 54 | (b'Content-Length', str(len(response)).encode('ascii')), 55 | (b'Content-Type', b'text/html; charset=UTF-8') 56 | ] 57 | self.conn.send_headers(request_id, headers, 200) 58 | self.conn.send_data(request_id, response, end_request=True) 59 | 60 | 61 | loop = get_event_loop() 62 | coro = loop.create_server(FastCGIProtocol, port=9500, reuse_address=True) 63 | loop.run_until_complete(coro) 64 | 65 | try: 66 | loop.run_forever() 67 | except (KeyboardInterrupt, SystemExit): 68 | pass 69 | -------------------------------------------------------------------------------- /examples/curio-server.py: -------------------------------------------------------------------------------- 1 | from curio import run, spawn 2 | from curio.socket import * 3 | 4 | from fcgiproto import FastCGIConnection, RequestBeginEvent, RequestDataEvent 5 | 6 | 7 | async def fcgi_server(address): 8 | sock = socket(AF_INET, SOCK_STREAM) 9 | sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 10 | sock.bind(address) 11 | sock.listen(5) 12 | async with sock: 13 | while True: 14 | client, addr = await sock.accept() 15 | await spawn(fcgi_client(client, addr)) 16 | 17 | 18 | def handle_request(conn, request_id, params, content): 19 | fcgi_params = '\n'.join('%s%s' % (key, value) 20 | for key, value in params.items()) 21 | content = content.decode('utf-8', errors='replace') 22 | response = ("""\ 23 | 24 | 25 | 26 |

FCGI parameters

27 | 28 | %s 29 |
30 |

Request body

31 |
%s
32 | 33 | 34 | """ % (fcgi_params, content)).encode('utf-8') 35 | headers = [ 36 | (b'Content-Length', str(len(response)).encode('ascii')), 37 | (b'Content-Type', b'text/html; charset=UTF-8') 38 | ] 39 | conn.send_headers(request_id, headers, 200) 40 | conn.send_data(request_id, response, end_request=True) 41 | 42 | 43 | async def fcgi_client(client, addr): 44 | conn = FastCGIConnection() 45 | requests = {} 46 | async with client: 47 | while True: 48 | data = await client.recv(100000) 49 | if not data: 50 | break 51 | 52 | for event in conn.feed_data(data): 53 | if isinstance(event, RequestBeginEvent): 54 | requests[event.request_id] = ( 55 | event.params, event.keep_connection, bytearray()) 56 | elif isinstance(event, RequestDataEvent): 57 | request_data = requests[event.request_id][2] 58 | if event.data: 59 | request_data.extend(event.data) 60 | else: 61 | params, keep_connection, request_data = requests.pop(event.request_id) 62 | handle_request(conn, event.request_id, params, request_data) 63 | if not keep_connection: 64 | break 65 | 66 | data = conn.data_to_send() 67 | if data: 68 | await client.sendall(data) 69 | 70 | if __name__ == '__main__': 71 | try: 72 | run(fcgi_server(('', 9500))) 73 | except (KeyboardInterrupt, SystemExit): 74 | pass 75 | -------------------------------------------------------------------------------- /examples/nginx_docker.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | include fastcgi_params; 5 | fastcgi_pass 172.17.0.1:9500; 6 | fastcgi_keep_conn on; 7 | } 8 | } -------------------------------------------------------------------------------- /examples/nginx_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run this in the same directory! 4 | docker run --rm -p 80:80 -v $PWD/nginx_docker.conf:/etc/nginx/conf.d/default.conf:ro nginx 5 | -------------------------------------------------------------------------------- /examples/twisted-server.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor 2 | from twisted.internet.endpoints import TCP4ServerEndpoint 3 | from twisted.internet.protocol import Protocol, Factory 4 | 5 | from fcgiproto import FastCGIConnection, RequestBeginEvent, RequestDataEvent 6 | 7 | 8 | class FastCGIProtocol(Protocol): 9 | def __init__(self): 10 | self.conn = FastCGIConnection() 11 | self.requests = {} 12 | 13 | def dataReceived(self, data): 14 | try: 15 | for event in self.conn.feed_data(data): 16 | if isinstance(event, RequestBeginEvent): 17 | self.requests[event.request_id] = ( 18 | event.params, event.keep_connection, bytearray()) 19 | elif isinstance(event, RequestDataEvent): 20 | request_data = self.requests[event.request_id][2] 21 | if event.data: 22 | request_data.extend(event.data) 23 | else: 24 | params, keep_connection, request_data = self.requests.pop(event.request_id) 25 | self.handle_request(event.request_id, params, request_data) 26 | if not keep_connection: 27 | self.transport.loseConnection() 28 | 29 | self.transport.write(self.conn.data_to_send()) 30 | except Exception: 31 | self.transport.abortConnection() 32 | raise 33 | 34 | def handle_request(self, request_id, params, content): 35 | fcgi_params = '\n'.join('%s%s' % (key, value) 36 | for key, value in params.items()) 37 | content = content.decode('utf-8', errors='replace') 38 | response = ("""\ 39 | 40 | 41 | 42 |

FCGI parameters

43 | 44 | %s 45 |
46 |

Request body

47 |
%s
48 | 49 | 50 | """ % (fcgi_params, content)).encode('utf-8') 51 | headers = [ 52 | (b'Content-Length', str(len(response)).encode('ascii')), 53 | (b'Content-Type', b'text/html; charset=UTF-8') 54 | ] 55 | self.conn.send_headers(request_id, headers, 200) 56 | self.conn.send_data(request_id, response, end_request=True) 57 | 58 | 59 | class FastCGIFactory(Factory): 60 | def buildProtocol(self, addr): 61 | return FastCGIProtocol() 62 | 63 | 64 | endpoint = TCP4ServerEndpoint(reactor, 9500) 65 | endpoint.listen(FastCGIFactory()) 66 | 67 | try: 68 | reactor.run() 69 | except (KeyboardInterrupt, SystemExit): 70 | pass 71 | -------------------------------------------------------------------------------- /fcgiproto/__init__.py: -------------------------------------------------------------------------------- 1 | from .connection import FastCGIConnection # noqa 2 | from .constants import FCGI_RESPONDER, FCGI_AUTHORIZER, FCGI_FILTER # noqa 3 | from .events import ( # noqa 4 | RequestEvent, RequestBeginEvent, RequestAbortEvent, RequestDataEvent, 5 | RequestSecondaryDataEvent) 6 | from .exceptions import ProtocolError # noqa 7 | -------------------------------------------------------------------------------- /fcgiproto/connection.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from fcgiproto.constants import ( 4 | FCGI_REQUEST_COMPLETE, FCGI_GET_VALUES, FCGI_RESPONDER, FCGI_BEGIN_REQUEST, FCGI_UNKNOWN_ROLE) 5 | from fcgiproto.records import ( 6 | FCGIStdout, FCGIEndRequest, FCGIGetValuesResult, FCGIUnknownType, decode_record) 7 | from fcgiproto.states import RequestState 8 | 9 | 10 | class FastCGIConnection(object): 11 | """ 12 | FastCGIConnection(roles=(FCGI_RESPONDER,), fcgi_values=None) 13 | 14 | FastCGI connection state machine. 15 | 16 | :param roles: iterable of allowed application roles (``FCGI_RESPONDER``, ``FCGI_AUTHORIZER``, 17 | ``FCGI_FILTER``) 18 | :param dict fcgi_values: dictionary of FastCGI management values (see the 19 | `FastCGI specification`_ for a list); keys and values must be unicode strings 20 | 21 | .. _FastCGI specification: https://htmlpreview.github.io/?https://github.com/FastCGI-Archives/\ 22 | FastCGI.com/blob/master/docs/FastCGI%20Specification.html 23 | 24 | """ 25 | 26 | __slots__ = ('roles', 'fcgi_values', '_input_buffer', '_output_buffer', '_request_states') 27 | 28 | def __init__(self, roles=(FCGI_RESPONDER,), fcgi_values=None): 29 | self.roles = frozenset(roles) 30 | self.fcgi_values = fcgi_values or {} 31 | self.fcgi_values.setdefault(u'FCGI_MPXS_CONNS', u'1') 32 | self._input_buffer = bytearray() 33 | self._output_buffer = bytearray() 34 | self._request_states = defaultdict(RequestState) 35 | 36 | def feed_data(self, data): 37 | """ 38 | Feed data to the internal buffer of the connection. 39 | 40 | If there is enough data to generate one or more events, they will be added to the list 41 | returned from this call. 42 | 43 | Sometimes this call generates outgoing data so it is important to call 44 | :meth:`.data_to_send` afterwards and write those bytes to the output. 45 | 46 | :param bytes data: incoming data 47 | :raise fcgiproto.ProtocolError: if the protocol is violated 48 | :return: the list of generated FastCGI events 49 | :rtype: list 50 | 51 | """ 52 | self._input_buffer.extend(data) 53 | events = [] 54 | while True: 55 | record = decode_record(self._input_buffer) 56 | if record is None: 57 | return events 58 | 59 | if record.request_id: 60 | request_state = self._request_states[record.request_id] 61 | event = request_state.receive_record(record) 62 | if record.record_type == FCGI_BEGIN_REQUEST and record.role not in self.roles: 63 | # Reject requests where the role isn't among our set of allowed roles 64 | self._send_record(FCGIEndRequest(record.request_id, 0, FCGI_UNKNOWN_ROLE)) 65 | elif event is not None: 66 | events.append(event) 67 | else: 68 | if record.record_type == FCGI_GET_VALUES: 69 | pairs = [(key, self.fcgi_values[key]) for key in record.keys 70 | if key in self.fcgi_values] 71 | self._send_record(FCGIGetValuesResult(pairs)) 72 | else: 73 | self._send_record(FCGIUnknownType(record.record_type)) 74 | 75 | def data_to_send(self): 76 | """ 77 | Return any data that is due to be sent to the other end. 78 | 79 | :rtype: bytes 80 | 81 | """ 82 | data = bytes(self._output_buffer) 83 | del self._output_buffer[:] 84 | return data 85 | 86 | def send_headers(self, request_id, headers, status=None): 87 | """ 88 | Send response headers for the given request. 89 | 90 | Header keys will be converted from unicode strings to bytestrings if necessary. 91 | Values will be converted from any type to bytestrings if necessary. 92 | 93 | :param int request_id: identifier of the request 94 | :param headers: an iterable of (key, value) tuples of bytestrings 95 | :param int status: the response status code, if not 200 96 | :raise fcgiproto.ProtocolError: if the protocol is violated 97 | 98 | """ 99 | payload = bytearray() 100 | 101 | if status: 102 | payload.extend((u'Status: %d\r\n' % status).encode('ascii')) 103 | 104 | for key, value in headers: 105 | if not isinstance(key, bytes): 106 | raise TypeError('header keys must be bytestrings, not %s' % key.__class__.__name__) 107 | if not isinstance(value, bytes): 108 | raise TypeError('header values must be bytestrings, not %s' % 109 | value.__class__.__name__) 110 | 111 | payload.extend(key + b': ' + value + b'\r\n') 112 | 113 | payload.extend(b'\r\n') 114 | record = FCGIStdout(request_id, payload) 115 | self._send_record(record) 116 | 117 | def send_data(self, request_id, data, end_request=False): 118 | """ 119 | Send response body data for the given request. 120 | 121 | This method may be called several times before :meth:`.end_request`. 122 | 123 | :param int request_id: identifier of the request 124 | :param bytes data: request body data 125 | :param bool end_request: ``True`` to finish the request 126 | :raise fcgiproto.ProtocolError: if the protocol is violated 127 | 128 | """ 129 | self._send_record(FCGIStdout(request_id, data)) 130 | if end_request: 131 | self._send_record(FCGIStdout(request_id, b'')) 132 | self._send_record(FCGIEndRequest(request_id, 0, FCGI_REQUEST_COMPLETE)) 133 | 134 | def end_request(self, request_id): 135 | """ 136 | Mark the given request finished. 137 | 138 | :param int request_id: identifier of the request 139 | :raise fcgiproto.ProtocolError: if the protocol is violated 140 | 141 | """ 142 | self._send_record(FCGIEndRequest(request_id, 0, FCGI_REQUEST_COMPLETE)) 143 | 144 | def _send_record(self, record): 145 | if record.request_id: 146 | request_state = self._request_states[record.request_id] 147 | request_state.send_record(record) 148 | if request_state.state == RequestState.FINISHED: 149 | del self._request_states[record.request_id] 150 | 151 | self._output_buffer.extend(record.encode()) 152 | -------------------------------------------------------------------------------- /fcgiproto/connection.pyi: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import List, Iterable, Tuple, Any 3 | from typing import Set 4 | 5 | from fcgiproto.constants import FCGI_RESPONDER 6 | from fcgiproto.events import RequestEvent 7 | from fcgiproto.records import FCGIRecord 8 | from fcgiproto.states import RequestState 9 | 10 | 11 | class FastCGIConnection: 12 | def __init__(self, roles: Iterable[int] = (FCGI_RESPONDER,), 13 | fcgi_values: Dict[str, str] = None) -> None: 14 | self.roles = None # type: Set[int] 15 | self.fcgi_values = None # type: Dict[str, str] 16 | self._input_buffer = None # type: bytearray 17 | self._output_buffer = None # type: bytearray 18 | self._request_states = None # type: Dict[int, RequestState] 19 | 20 | def feed_data(self, data: bytes) -> List[RequestEvent]: 21 | ... 22 | 23 | def data_to_send(self) -> bytes: 24 | ... 25 | 26 | def send_headers(self, request_id: int, headers: Iterable[Tuple[bytes, bytes]], 27 | status: int = None) -> None: 28 | ... 29 | 30 | def send_data(self, request_id: int, data: bytes, end_request: bool = False) -> None: 31 | ... 32 | 33 | def end_request(self, request_id: int) -> None: 34 | ... 35 | 36 | def _send_record(self, record: FCGIRecord) -> None: 37 | ... 38 | -------------------------------------------------------------------------------- /fcgiproto/constants.py: -------------------------------------------------------------------------------- 1 | # Values for type component of FCGIHeader 2 | FCGI_BEGIN_REQUEST = 1 3 | FCGI_ABORT_REQUEST = 2 4 | FCGI_END_REQUEST = 3 5 | FCGI_PARAMS = 4 6 | FCGI_STDIN = 5 7 | FCGI_STDOUT = 6 8 | FCGI_STDERR = 7 9 | FCGI_DATA = 8 10 | FCGI_GET_VALUES = 9 11 | FCGI_GET_VALUES_RESULT = 10 12 | FCGI_UNKNOWN_TYPE = 11 13 | 14 | # Mask for flags component of FCGIBeginRequestBody 15 | FCGI_KEEP_CONN = 1 16 | 17 | # Values for role component of FCGIBeginRequestBody 18 | FCGI_RESPONDER = 1 19 | FCGI_AUTHORIZER = 2 20 | FCGI_FILTER = 3 21 | 22 | # Values for protocol_status component of FCGIEndRequestBody 23 | FCGI_REQUEST_COMPLETE = 0 24 | FCGI_CANT_MPX_CONN = 1 25 | FCGI_OVERLOADED = 2 26 | FCGI_UNKNOWN_ROLE = 3 27 | -------------------------------------------------------------------------------- /fcgiproto/events.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from fcgiproto.constants import FCGI_KEEP_CONN 4 | 5 | 6 | class RequestEvent(object): 7 | """ 8 | Base class for events that target a specific request. 9 | 10 | :ivar int request_id: identifier of the associated request 11 | """ 12 | 13 | __slots__ = ('request_id',) 14 | 15 | def __init__(self, request_id): 16 | self.request_id = request_id 17 | 18 | 19 | class RequestBeginEvent(RequestEvent): 20 | """ 21 | Signals the application about a new incoming request. 22 | 23 | :ivar int request_id: identifier of the request 24 | :ivar int role: expected role of the application for the request 25 | one of (``FCGI_RESPONDER``, ``FCGI_AUTHORIZER``, ``FCGI_FILTER``) 26 | :ivar dict params: FCGI parameters for the request 27 | """ 28 | 29 | __slots__ = ('role', 'keep_connection', 'params') 30 | 31 | def __init__(self, request_id, role, flags, params): 32 | super(RequestBeginEvent, self).__init__(request_id) 33 | self.role = role 34 | self.keep_connection = flags & FCGI_KEEP_CONN 35 | self.params = OrderedDict(params) 36 | 37 | 38 | class RequestDataEvent(RequestEvent): 39 | """ 40 | Contains body data for the specified request. 41 | 42 | An empty ``data`` argument signifies the end of the data stream. 43 | 44 | :ivar int request_id: identifier of the request 45 | :ivar bytes data: bytestring containing raw request data 46 | """ 47 | 48 | __slots__ = ('data',) 49 | 50 | def __init__(self, request_id, data): 51 | super(RequestDataEvent, self).__init__(request_id) 52 | self.data = data 53 | 54 | 55 | class RequestSecondaryDataEvent(RequestEvent): 56 | """ 57 | Contains secondary data for the specified request. 58 | 59 | An empty ``data`` argument signifies the end of the data stream. 60 | 61 | These events are only received for the ``FCGI_FILTER`` role. 62 | 63 | :ivar int request_id: identifier of the request 64 | :ivar bytes data: bytestring containing raw secondary data 65 | """ 66 | 67 | __slots__ = ('data',) 68 | 69 | def __init__(self, request_id, data): 70 | super(RequestSecondaryDataEvent, self).__init__(request_id) 71 | self.data = data 72 | 73 | 74 | class RequestAbortEvent(RequestEvent): 75 | """Signals the application that the server wants the specified request aborted.""" 76 | 77 | __slots__ = () 78 | -------------------------------------------------------------------------------- /fcgiproto/exceptions.py: -------------------------------------------------------------------------------- 1 | class ProtocolError(Exception): 2 | """Raised by the state machine when the FastCGI protocol is being violated.""" 3 | 4 | def __init__(self, message): 5 | super(ProtocolError, self).__init__('FastCGI protocol violation: %s' % message) 6 | -------------------------------------------------------------------------------- /fcgiproto/records.py: -------------------------------------------------------------------------------- 1 | from struct import Struct 2 | 3 | from fcgiproto.constants import ( 4 | FCGI_BEGIN_REQUEST, FCGI_PARAMS, FCGI_END_REQUEST, FCGI_UNKNOWN_TYPE, FCGI_ABORT_REQUEST, 5 | FCGI_STDOUT, FCGI_STDIN, FCGI_STDERR, FCGI_DATA, FCGI_GET_VALUES, FCGI_GET_VALUES_RESULT) 6 | from fcgiproto.exceptions import ProtocolError 7 | 8 | headers_struct = Struct('>BBHHBx') 9 | length4_struct = Struct('>I') 10 | 11 | 12 | class FCGIRecord(object): 13 | __slots__ = ('request_id',) 14 | 15 | struct = Struct('') # type: Struct 16 | record_type = None # type: int 17 | 18 | def __init__(self, request_id): 19 | self.request_id = request_id 20 | 21 | @classmethod 22 | def parse(cls, request_id, content): 23 | fields = cls.struct.unpack(content) 24 | return cls(request_id, *fields) 25 | 26 | def encode_header(self, content): 27 | return headers_struct.pack(1, self.record_type, self.request_id, len(content), 0) 28 | 29 | def encode(self): # pragma: no cover 30 | raise NotImplementedError 31 | 32 | 33 | class FCGIBytestreamRecord(FCGIRecord): 34 | __slots__ = ('content',) 35 | 36 | def __init__(self, request_id, content): 37 | super(FCGIBytestreamRecord, self).__init__(request_id) 38 | self.content = content 39 | 40 | @classmethod 41 | def parse(cls, request_id, content): 42 | return cls(request_id, bytes(content)) 43 | 44 | def encode(self): 45 | return self.encode_header(self.content) + self.content 46 | 47 | 48 | class FCGIUnknownManagementRecord(FCGIRecord): 49 | def __init__(self, record_type): 50 | super(FCGIUnknownManagementRecord, self).__init__(0) 51 | self.record_type = record_type 52 | 53 | 54 | class FCGIGetValues(FCGIRecord): 55 | __slots__ = ('keys',) 56 | 57 | record_type = FCGI_GET_VALUES 58 | 59 | def __init__(self, keys): 60 | super(FCGIGetValues, self).__init__(0) 61 | self.keys = keys 62 | 63 | @classmethod 64 | def parse(cls, request_id, content): 65 | assert request_id == 0 66 | keys = [key for key, value in decode_name_value_pairs(content)] 67 | return cls(keys) 68 | 69 | def encode(self): 70 | pairs = [(key, '') for key in self.keys] 71 | content = encode_name_value_pairs(pairs) 72 | return self.encode_header(content) + content 73 | 74 | 75 | class FCGIGetValuesResult(FCGIRecord): 76 | __slots__ = ('values',) 77 | 78 | record_type = FCGI_GET_VALUES_RESULT 79 | 80 | def __init__(self, values): 81 | super(FCGIGetValuesResult, self).__init__(0) 82 | self.values = values 83 | 84 | @classmethod 85 | def parse(cls, request_id, content): 86 | assert request_id == 0 87 | values = decode_name_value_pairs(content) 88 | return cls(values) 89 | 90 | def encode(self): 91 | content = encode_name_value_pairs(self.values) 92 | return self.encode_header(content) + content 93 | 94 | 95 | class FCGIUnknownType(FCGIRecord): 96 | __slots__ = ('type',) 97 | 98 | struct = Struct('>B7x') 99 | record_type = FCGI_UNKNOWN_TYPE 100 | 101 | def __init__(self, type): 102 | assert type > FCGI_UNKNOWN_TYPE 103 | super(FCGIUnknownType, self).__init__(0) 104 | self.type = type 105 | 106 | def encode(self): 107 | content = self.struct.pack(self.type) 108 | return self.encode_header(content) + content 109 | 110 | 111 | class FCGIBeginRequest(FCGIRecord): 112 | __slots__ = ('role', 'flags') 113 | 114 | struct = Struct('>HB5x') 115 | record_type = FCGI_BEGIN_REQUEST 116 | 117 | def __init__(self, request_id, role, flags): 118 | super(FCGIBeginRequest, self).__init__(request_id) 119 | self.role = role 120 | self.flags = flags 121 | 122 | def encode(self): 123 | content = self.struct.pack(self.role, self.flags) 124 | return self.encode_header(content) + content 125 | 126 | 127 | class FCGIAbortRequest(FCGIRecord): 128 | __slots__ = () 129 | 130 | record_type = FCGI_ABORT_REQUEST 131 | 132 | @classmethod 133 | def parse(cls, request_id, content): 134 | return cls(request_id) 135 | 136 | def encode(self): 137 | return self.encode_header(b'') 138 | 139 | 140 | class FCGIParams(FCGIBytestreamRecord): 141 | __slots__ = () 142 | 143 | record_type = FCGI_PARAMS 144 | 145 | 146 | class FCGIStdin(FCGIBytestreamRecord): 147 | __slots__ = () 148 | 149 | record_type = FCGI_STDIN 150 | 151 | 152 | class FCGIStdout(FCGIBytestreamRecord): 153 | __slots__ = () 154 | 155 | record_type = FCGI_STDOUT 156 | 157 | 158 | class FCGIStderr(FCGIBytestreamRecord): 159 | __slots__ = () 160 | 161 | record_type = FCGI_STDERR 162 | 163 | 164 | class FCGIData(FCGIBytestreamRecord): 165 | __slots__ = () 166 | 167 | record_type = FCGI_DATA 168 | 169 | 170 | class FCGIEndRequest(FCGIRecord): 171 | __slots__ = ('app_status', 'protocol_status') 172 | 173 | struct = Struct('>IB3x') 174 | record_type = FCGI_END_REQUEST 175 | 176 | def __init__(self, request_id, app_status, protocol_status): 177 | super(FCGIEndRequest, self).__init__(request_id) 178 | self.app_status = app_status 179 | self.protocol_status = protocol_status 180 | 181 | def encode(self): 182 | content = self.struct.pack(self.app_status, self.protocol_status) 183 | return self.encode_header(content) + content 184 | 185 | 186 | record_classes = {cls.record_type: cls for cls in globals().values() # type: ignore 187 | if isinstance(cls, type) and issubclass(cls, FCGIRecord) 188 | and cls.record_type} # type: ignore 189 | 190 | 191 | def decode_name_value_pairs(buffer): 192 | """ 193 | Decode a name-value pair list from a buffer. 194 | 195 | :param bytearray buffer: a buffer containing a FastCGI name-value pair list 196 | :raise ProtocolError: if the buffer contains incomplete data 197 | :return: a list of (name, value) tuples where both elements are unicode strings 198 | :rtype: list 199 | 200 | """ 201 | index = 0 202 | pairs = [] 203 | while index < len(buffer): 204 | if buffer[index] & 0x80 == 0: 205 | name_length = buffer[index] 206 | index += 1 207 | elif len(buffer) - index > 4: 208 | name_length = length4_struct.unpack_from(buffer, index)[0] & 0x7fffffff 209 | index += 4 210 | else: 211 | raise ProtocolError('not enough data to decode name length in name-value pair') 212 | 213 | if len(buffer) - index > 1 and buffer[index] & 0x80 == 0: 214 | value_length = buffer[index] 215 | index += 1 216 | elif len(buffer) - index > 4: 217 | value_length = length4_struct.unpack_from(buffer, index)[0] & 0x7fffffff 218 | index += 4 219 | else: 220 | raise ProtocolError('not enough data to decode value length in name-value pair') 221 | 222 | if len(buffer) - index >= name_length + value_length: 223 | name = buffer[index:index + name_length].decode('ascii') 224 | value = buffer[index + name_length:index + name_length + value_length].decode('utf-8') 225 | pairs.append((name, value)) 226 | index += name_length + value_length 227 | else: 228 | raise ProtocolError('name/value data missing from buffer') 229 | 230 | return pairs 231 | 232 | 233 | def encode_name_value_pairs(pairs): 234 | """ 235 | Encode a list of name-pair values into a binary form that FCGI understands. 236 | 237 | Both names and values can be either unicode strings or bytestrings and will be converted to 238 | bytestrings as necessary. 239 | 240 | :param list pairs: list of name-value pairs 241 | :return: the encoded bytestring 242 | 243 | """ 244 | content = bytearray() 245 | for name, value in pairs: 246 | name = name if isinstance(name, bytes) else name.encode('ascii') 247 | value = value if isinstance(value, bytes) else value.encode('ascii') 248 | for item in (name, value): 249 | if len(item) < 128: 250 | content.append(len(item)) 251 | else: 252 | length = len(item) 253 | content.extend(length4_struct.pack(length | 0x80000000)) 254 | 255 | content.extend(name) 256 | content.extend(value) 257 | 258 | return bytes(content) 259 | 260 | 261 | def decode_record(buffer): 262 | """ 263 | Create a new FCGI message from the bytes in the given buffer. 264 | 265 | If successful, the record's data is removed from the byte array. 266 | 267 | :param bytearray buffer: the byte array containing the data 268 | :return: an instance of this class, or ``None`` if there was not enough data 269 | 270 | """ 271 | if len(buffer) >= headers_struct.size: 272 | version, record_type, request_id, content_length, padding_length = \ 273 | headers_struct.unpack_from(buffer) 274 | if version != 1: 275 | raise ProtocolError('unexpected protocol version: %d' % buffer[0]) 276 | elif len(buffer) >= headers_struct.size + content_length + padding_length: 277 | content = buffer[headers_struct.size:headers_struct.size + content_length] 278 | del buffer[:headers_struct.size + content_length + padding_length] 279 | try: 280 | record_class = record_classes[record_type] 281 | except KeyError: 282 | if request_id: 283 | raise ProtocolError('unknown record type: %d' % record_type) 284 | else: 285 | return FCGIUnknownManagementRecord(record_type) 286 | 287 | return record_class.parse(request_id, content) 288 | 289 | return None 290 | -------------------------------------------------------------------------------- /fcgiproto/states.py: -------------------------------------------------------------------------------- 1 | from fcgiproto.constants import ( 2 | FCGI_BEGIN_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT, FCGI_END_REQUEST, FCGI_DATA, 3 | FCGI_FILTER, FCGI_AUTHORIZER, FCGI_ABORT_REQUEST, FCGI_REQUEST_COMPLETE) 4 | from fcgiproto.events import ( 5 | RequestDataEvent, RequestSecondaryDataEvent, RequestAbortEvent, RequestBeginEvent) 6 | from fcgiproto.exceptions import ProtocolError 7 | from fcgiproto.records import decode_name_value_pairs 8 | 9 | 10 | class RequestState(object): 11 | __slots__ = ('state', 'role', 'flags', 'params_buffer') 12 | 13 | EXPECT_BEGIN_REQUEST = 1 14 | EXPECT_PARAMS = 2 15 | EXPECT_STDIN = 3 16 | EXPECT_DATA = 4 17 | EXPECT_STDOUT = 5 18 | EXPECT_END_REQUEST = 6 19 | FINISHED = 7 20 | state_names = {value: varname for varname, value in locals().items() if isinstance(value, int)} 21 | 22 | def __init__(self): 23 | self.state = RequestState.EXPECT_BEGIN_REQUEST 24 | self.role = self.flags = None 25 | self.params_buffer = bytearray() 26 | 27 | def receive_record(self, record): 28 | if record.record_type == FCGI_BEGIN_REQUEST: 29 | if self.state == RequestState.EXPECT_BEGIN_REQUEST: 30 | self.role = record.role 31 | self.flags = record.flags 32 | self.state = RequestState.EXPECT_PARAMS 33 | return None 34 | elif record.record_type == FCGI_PARAMS: 35 | if self.state == RequestState.EXPECT_PARAMS: 36 | if record.content: 37 | self.params_buffer.extend(record.content) 38 | return None 39 | else: 40 | params = decode_name_value_pairs(self.params_buffer) 41 | if self.role == FCGI_AUTHORIZER: 42 | self.state = RequestState.EXPECT_STDOUT 43 | else: 44 | self.state = RequestState.EXPECT_STDIN 45 | 46 | return RequestBeginEvent(record.request_id, self.role, self.flags, params) 47 | elif record.record_type == FCGI_STDIN: 48 | if self.state == RequestState.EXPECT_STDIN: 49 | if not record.content: 50 | if self.role == FCGI_FILTER: 51 | self.state = RequestState.EXPECT_DATA 52 | else: 53 | self.state = RequestState.EXPECT_STDOUT 54 | 55 | return RequestDataEvent(record.request_id, record.content) 56 | elif record.record_type == FCGI_DATA: 57 | if self.state == RequestState.EXPECT_DATA: 58 | if not record.content: 59 | self.state = RequestState.EXPECT_STDOUT 60 | 61 | return RequestSecondaryDataEvent(record.request_id, record.content) 62 | elif record.record_type == FCGI_ABORT_REQUEST: 63 | if RequestState.EXPECT_BEGIN_REQUEST < self.state < RequestState.FINISHED: 64 | self.state = RequestState.EXPECT_END_REQUEST 65 | return RequestAbortEvent(record.request_id) 66 | 67 | raise ProtocolError('received unexpected %s record in the %s state' % ( 68 | record.__class__.__name__, self.state_names[self.state])) 69 | 70 | def send_record(self, record): 71 | if record.record_type == FCGI_STDOUT: 72 | if self.state == RequestState.EXPECT_STDOUT: 73 | if not record.content: 74 | self.state = RequestState.EXPECT_END_REQUEST 75 | 76 | return 77 | elif record.record_type == FCGI_END_REQUEST: 78 | # Only allow a normal request finish when it's expected 79 | if self.state == RequestState.EXPECT_END_REQUEST: 80 | if record.protocol_status == FCGI_REQUEST_COMPLETE: 81 | self.state = RequestState.FINISHED 82 | return 83 | elif self.state == RequestState.EXPECT_PARAMS: 84 | # Allow rejecting the request right after receiving it but not later 85 | if record.protocol_status != FCGI_REQUEST_COMPLETE: 86 | self.state = RequestState.FINISHED 87 | return 88 | 89 | raise ProtocolError('cannot send %s record in the %s state' % ( 90 | record.__class__.__name__, self.state_names[self.state])) 91 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = docs 3 | build-dir = docs/_build 4 | 5 | [upload_docs] 6 | upload-dir = docs/_build/html 7 | 8 | [tool:pytest] 9 | addopts = -rsx --cov --tb=short 10 | norecursedirs = .git .tox .cache build docs examples virtualenv 11 | 12 | [flake8] 13 | max-line-length = 99 14 | exclude = .tox,build,docs 15 | 16 | [bdist_wheel] 17 | universal = 1 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os.path 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | here = os.path.dirname(__file__) 8 | readme_path = os.path.join(here, 'README.rst') 9 | readme = open(readme_path).read() 10 | 11 | setup( 12 | name='fcgiproto', 13 | use_scm_version={ 14 | 'version_scheme': 'post-release', 15 | 'local_scheme': 'dirty-tag' 16 | }, 17 | description='FastCGI state-machine based protocol implementation', 18 | long_description=readme, 19 | author=u'Alex Grönholm', 20 | author_email='alex.gronholm@nextday.fi', 21 | url='https://github.com/agronholm/fcgiproto', 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.3', 30 | 'Programming Language :: Python :: 3.4', 31 | 'Programming Language :: Python :: 3.5', 32 | 'Programming Language :: Python :: 3.6' 33 | ], 34 | keywords='fastcgi http', 35 | license='MIT', 36 | packages=find_packages(exclude=['tests']), 37 | include_package_data=True, 38 | setup_requires=['setuptools_scm'] 39 | ) 40 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fcgiproto.connection import FastCGIConnection 4 | from fcgiproto.constants import ( 5 | FCGI_RESPONDER, FCGI_AUTHORIZER, FCGI_FILTER, FCGI_REQUEST_COMPLETE, FCGI_UNKNOWN_ROLE) 6 | from fcgiproto.events import ( 7 | RequestBeginEvent, RequestAbortEvent, RequestDataEvent, RequestSecondaryDataEvent) 8 | from fcgiproto.records import ( 9 | FCGIBeginRequest, FCGIStdin, FCGIParams, FCGIStdout, FCGIEndRequest, encode_name_value_pairs, 10 | FCGIAbortRequest, FCGIGetValues, FCGIGetValuesResult, FCGIUnknownType, FCGIData) 11 | 12 | 13 | @pytest.fixture 14 | def conn(): 15 | return FastCGIConnection() 16 | 17 | 18 | @pytest.mark.parametrize('send_status', [True, False]) 19 | def test_responder_request(conn, send_status): 20 | events = conn.feed_data(FCGIBeginRequest(1, FCGI_RESPONDER, 0).encode()) 21 | assert len(events) == 0 22 | 23 | content = encode_name_value_pairs([('REQUEST_METHOD', 'GET'), ('CONTENT_LENGTH', '')]) 24 | events = conn.feed_data(FCGIParams(1, content).encode()) 25 | assert len(events) == 0 26 | 27 | events = conn.feed_data(FCGIParams(1, b'').encode()) 28 | assert isinstance(events[0], RequestBeginEvent) 29 | 30 | events = conn.feed_data(FCGIStdin(1, b'content').encode()) 31 | assert len(events) == 1 32 | assert isinstance(events[0], RequestDataEvent) 33 | 34 | events = conn.feed_data(FCGIStdin(1, b'').encode()) 35 | assert len(events) == 1 36 | assert isinstance(events[0], RequestDataEvent) 37 | 38 | headers = [(b'Content-Length', b'7'), (b'Content-Type', b'text/plain')] 39 | conn.send_headers(1, headers, status=200 if send_status else None) 40 | expected_body = b'Content-Length: 7\r\nContent-Type: text/plain\r\n\r\n' 41 | if send_status: 42 | expected_body = b'Status: 200\r\n' + expected_body 43 | assert conn.data_to_send() == \ 44 | FCGIStdout(1, expected_body).encode() 45 | 46 | conn.send_data(1, b'Cont') 47 | assert conn.data_to_send() == FCGIStdout(1, b'Cont').encode() 48 | 49 | conn.send_data(1, b'ent', end_request=True) 50 | assert conn.data_to_send() == FCGIStdout(1, b'ent').encode() + \ 51 | FCGIStdout(1, b'').encode() + FCGIEndRequest(1, 0, FCGI_REQUEST_COMPLETE).encode() 52 | 53 | 54 | def test_authorizer_request(): 55 | conn = FastCGIConnection(roles=[FCGI_AUTHORIZER]) 56 | events = conn.feed_data(FCGIBeginRequest(1, FCGI_AUTHORIZER, 0).encode()) 57 | assert len(events) == 0 58 | 59 | content = encode_name_value_pairs([('REQUEST_METHOD', 'GET'), ('CONTENT_LENGTH', '')]) 60 | events = conn.feed_data(FCGIParams(1, content).encode()) 61 | assert len(events) == 0 62 | 63 | events = conn.feed_data(FCGIParams(1, b'').encode()) 64 | assert len(events) == 1 65 | assert isinstance(events[0], RequestBeginEvent) 66 | 67 | headers = [(b'Content-Length', b'13'), (b'Content-Type', b'text/plain')] 68 | conn.send_headers(1, headers, status=403) 69 | assert conn.data_to_send() == \ 70 | FCGIStdout(1, b'Status: 403\r\nContent-Length: 13\r\n' 71 | b'Content-Type: text/plain\r\n\r\n').encode() 72 | 73 | conn.send_data(1, b'Access denied', end_request=True) 74 | assert conn.data_to_send() == \ 75 | FCGIStdout(1, b'Access denied').encode() + FCGIStdout(1, b'').encode() + \ 76 | FCGIEndRequest(1, 0, FCGI_REQUEST_COMPLETE).encode() 77 | 78 | 79 | def test_filter_request(): 80 | conn = FastCGIConnection(roles=[FCGI_FILTER]) 81 | events = conn.feed_data(FCGIBeginRequest(1, FCGI_FILTER, 0).encode()) 82 | assert len(events) == 0 83 | 84 | content = encode_name_value_pairs([('REQUEST_METHOD', 'GET'), ('CONTENT_LENGTH', '')]) 85 | events = conn.feed_data(FCGIParams(1, content).encode()) 86 | assert len(events) == 0 87 | 88 | events = conn.feed_data(FCGIParams(1, b'').encode()) 89 | assert isinstance(events[0], RequestBeginEvent) 90 | 91 | events = conn.feed_data(FCGIStdin(1, b'content').encode()) 92 | assert len(events) == 1 93 | assert isinstance(events[0], RequestDataEvent) 94 | 95 | events = conn.feed_data(FCGIStdin(1, b'').encode()) 96 | assert len(events) == 1 97 | assert isinstance(events[0], RequestDataEvent) 98 | 99 | events = conn.feed_data(FCGIData(1, b'file data').encode()) 100 | assert len(events) == 1 101 | assert isinstance(events[0], RequestSecondaryDataEvent) 102 | 103 | events = conn.feed_data(FCGIData(1, b'').encode()) 104 | assert len(events) == 1 105 | assert isinstance(events[0], RequestSecondaryDataEvent) 106 | 107 | headers = [(b'Content-Length', b'9'), (b'Content-Type', b'application/octet-stream')] 108 | conn.send_headers(1, headers, status=404) 109 | assert conn.data_to_send() == \ 110 | FCGIStdout(1, b'Status: 404\r\nContent-Length: 9\r\n' 111 | b'Content-Type: application/octet-stream\r\n\r\n').\ 112 | encode() 113 | 114 | conn.send_data(1, b'file data', end_request=True) 115 | assert conn.data_to_send() == FCGIStdout(1, b'file data').encode() + \ 116 | FCGIStdout(1, b'').encode() + FCGIEndRequest(1, 0, FCGI_REQUEST_COMPLETE).encode() 117 | 118 | 119 | def test_aborted_request(conn): 120 | events = conn.feed_data(FCGIBeginRequest(1, FCGI_RESPONDER, 0).encode()) 121 | assert len(events) == 0 122 | 123 | content = encode_name_value_pairs([('REQUEST_METHOD', 'GET'), ('CONTENT_LENGTH', '')]) 124 | events = conn.feed_data(FCGIParams(1, content).encode()) 125 | assert len(events) == 0 126 | 127 | events = conn.feed_data(FCGIParams(1, b'').encode()) 128 | assert len(events) == 1 129 | assert isinstance(events[0], RequestBeginEvent) 130 | 131 | events = conn.feed_data(FCGIStdin(1, b'').encode()) 132 | assert len(events) == 1 133 | assert isinstance(events[0], RequestDataEvent) 134 | 135 | events = conn.feed_data(FCGIAbortRequest(1).encode()) 136 | assert len(events) == 1 137 | assert isinstance(events[0], RequestAbortEvent) 138 | 139 | conn.end_request(1) 140 | assert conn.data_to_send() == FCGIEndRequest(1, 0, FCGI_REQUEST_COMPLETE).encode() 141 | 142 | 143 | def test_unknown_role(conn): 144 | events = conn.feed_data(FCGIBeginRequest(1, FCGI_AUTHORIZER, 0).encode()) 145 | assert len(events) == 0 146 | assert conn.data_to_send() == FCGIEndRequest(1, 0, FCGI_UNKNOWN_ROLE).encode() 147 | 148 | 149 | def test_unknown_record_type(conn): 150 | events = conn.feed_data(b'\x01\x0c\x00\x00\x00\x00\x00\x00') 151 | assert len(events) == 0 152 | assert conn.data_to_send() == FCGIUnknownType(12).encode() 153 | 154 | 155 | def test_get_values(conn): 156 | keys = ['FCGI_MPXS_CONNS', 'FCGI_OTHER_KEY'] 157 | values = [('FCGI_MPXS_CONNS', '1')] 158 | events = conn.feed_data(FCGIGetValues(keys).encode()) 159 | assert len(events) == 0 160 | assert conn.data_to_send() == FCGIGetValuesResult(values).encode() 161 | 162 | 163 | def test_send_headers_invalid_key(conn): 164 | headers = [(1, b'value')] 165 | exc = pytest.raises(TypeError, conn.send_headers, 1, headers) 166 | assert str(exc.value) == 'header keys must be bytestrings, not int' 167 | 168 | 169 | def test_send_headers_invalid_value(conn): 170 | headers = [(b'Invalid', 1)] 171 | exc = pytest.raises(TypeError, conn.send_headers, 1, headers) 172 | assert str(exc.value) == 'header values must be bytestrings, not int' 173 | -------------------------------------------------------------------------------- /tests/test_records.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fcgiproto.records import ( 4 | encode_name_value_pairs, decode_name_value_pairs, decode_record, FCGIStdin, FCGIBeginRequest, 5 | FCGIEndRequest, FCGIUnknownType, FCGIStdout, FCGIGetValues, FCGIGetValuesResult, 6 | FCGIAbortRequest) 7 | from fcgiproto.exceptions import ProtocolError 8 | 9 | 10 | def test_encode_simple_record(): 11 | record = FCGIStdout(5, b'data') 12 | assert record.encode() == b'\x01\x06\x00\x05\x00\x04\x00\x00data' 13 | 14 | 15 | def test_parse_get_values(): 16 | buffer = bytearray(b'\x03\x00FOO\x03\x00BAR') 17 | record = FCGIGetValues.parse(0, buffer) 18 | assert record.keys == ['FOO', 'BAR'] 19 | 20 | 21 | def test_encode_get_values(): 22 | keys = ['FOO', 'BAR'] 23 | record = FCGIGetValues(keys) 24 | assert record.encode() == b'\x01\x09\x00\x00\x00\x0a\x00\x00\x03\x00FOO\x03\x00BAR' 25 | 26 | 27 | def test_parse_get_values_result(): 28 | buffer = bytearray(b'\x03\x03FOOabc\x03\x03BARxyz') 29 | record = FCGIGetValuesResult.parse(0, buffer) 30 | assert record.values == [('FOO', 'abc'), ('BAR', 'xyz')] 31 | 32 | 33 | def test_encode_get_values_result(): 34 | values = [('FOO', 'abc'), ('BAR', 'xyz')] 35 | record = FCGIGetValuesResult(values) 36 | assert record.encode() == b'\x01\x0a\x00\x00\x00\x10\x00\x00\x03\x03FOOabc\x03\x03BARxyz' 37 | 38 | 39 | def test_parse_begin_request(): 40 | buffer = bytearray(b'\x00\x01\x01\x00\x00\x00\x00\x00') 41 | record = FCGIBeginRequest.parse(5, buffer) 42 | assert record.request_id == 5 43 | assert record.role == 1 44 | assert record.flags == 1 45 | 46 | 47 | def test_encode_begin_request(): 48 | record = FCGIBeginRequest(5, 1, 1) 49 | assert record.encode() == b'\x01\x01\x00\x05\x00\x08\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00' 50 | 51 | 52 | def test_encode_abort_request(): 53 | record = FCGIAbortRequest(5) 54 | assert record.encode() == b'\x01\x02\x00\x05\x00\x00\x00\x00' 55 | 56 | 57 | def test_parse_abort_request(): 58 | buffer = bytearray(b'') 59 | record = FCGIAbortRequest.parse(5, buffer) 60 | assert record.request_id == 5 61 | 62 | 63 | def test_parse_end_request(): 64 | buffer = bytearray(b'\x00\x01\x00\x01\x02\x00\x00\x00') 65 | record = FCGIEndRequest.parse(5, buffer) 66 | assert record.request_id == 5 67 | assert record.app_status == 65537 68 | assert record.protocol_status == 2 69 | 70 | 71 | def test_encode_end_request(): 72 | record = FCGIEndRequest(5, 65537, 2) 73 | assert record.encode() == b'\x01\x03\x00\x05\x00\x08\x00\x00\x00\x01\x00\x01\x02\x00\x00\x00' 74 | 75 | 76 | def test_encode_unknown_type(): 77 | record = FCGIUnknownType(12) 78 | assert record.encode() == b'\x01\x0b\x00\x00\x00\x08\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00' 79 | 80 | 81 | @pytest.mark.parametrize('data, expected', [ 82 | (b'\x03\x06foobarbar\x01\x03Xxyz', [(u'foo', u'barbar'), ('X', 'xyz')]), 83 | (b'\x03\x80\x01\x00\x00foo' + b'x' * 65536, [(u'foo', u'x' * 65536)]), 84 | (b'\x80\x01\x00\x00\x03' + b'x' * 65536 + b'foo', [(u'x' * 65536, 'foo')]), 85 | (b'\x80\x01\x00\x00\x80\x01\x00\x00' + b'x' * 65536 + b'y' * 65536, 86 | [('x' * 65536, 'y' * 65536)]) 87 | ], ids=['short_both', 'long_value', 'long_name', 'long_both']) 88 | def test_decode_name_value_pairs(data, expected): 89 | buffer = bytearray(data) 90 | assert decode_name_value_pairs(buffer) == expected 91 | 92 | 93 | @pytest.mark.parametrize('data, message', [ 94 | (b'\x80\x00\x00', 'not enough data to decode name length in name-value pair'), 95 | (b'\x03', 'not enough data to decode value length in name-value pair'), 96 | (b'\x03\x06foo', 'name/value data missing from buffer') 97 | ], ids=['name_missing', 'value_missing', 'content_missing']) 98 | def test_decode_name_value_pairs_incomplete(data, message): 99 | buffer = bytearray(data) 100 | exc = pytest.raises(ProtocolError, decode_name_value_pairs, buffer) 101 | assert str(exc.value).endswith(message) 102 | 103 | 104 | @pytest.mark.parametrize('pairs, expected', [ 105 | ([(u'foo', u'barbar'), (u'X', u'xyz')], b'\x03\x06foobarbar\x01\x03Xxyz'), 106 | ([(u'foo', u'x' * 65536)], b'\x03\x80\x01\x00\x00foo' + b'x' * 65536), 107 | ([(u'x' * 65536, u'foo')], b'\x80\x01\x00\x00\x03' + b'x' * 65536 + b'foo'), 108 | ([(u'x' * 65536, u'y' * 65536)], 109 | b'\x80\x01\x00\x00\x80\x01\x00\x00' + b'x' * 65536 + b'y' * 65536) 110 | ], ids=['short_both', 'long_value', 'long_name', 'long_both']) 111 | def test_encode_name_value_pairs(pairs, expected): 112 | assert encode_name_value_pairs(pairs) == expected 113 | 114 | 115 | def test_decode_record(): 116 | buffer = bytearray(b'\x01\x05\x00\x01\x00\x07\x00\x00content') 117 | record = decode_record(buffer) 118 | assert isinstance(record, FCGIStdin) 119 | assert record.request_id == 1 120 | assert record.content == b'content' 121 | 122 | 123 | def test_decode_record_incomplete(): 124 | buffer = bytearray(b'\x01\x05\x00\x01\x00\x07\x00\x00conten') 125 | assert decode_record(buffer) is None 126 | 127 | 128 | def test_decode_record_wrong_version(): 129 | buffer = bytearray(b'\x02\x01\x00\x01\x00\x00\x00\x00') 130 | exc = pytest.raises(ProtocolError, decode_record, buffer) 131 | assert str(exc.value).endswith('unexpected protocol version: 2') 132 | 133 | 134 | def test_decode_unknown_record_type(): 135 | buffer = bytearray(b'\x01\x0c\x01\x00\x00\x00\x00\x00') 136 | exc = pytest.raises(ProtocolError, decode_record, buffer) 137 | assert str(exc.value).endswith('unknown record type: 12') 138 | -------------------------------------------------------------------------------- /tests/test_states.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fcgiproto.constants import ( 4 | FCGI_REQUEST_COMPLETE, FCGI_RESPONDER, FCGI_AUTHORIZER, FCGI_FILTER, FCGI_UNKNOWN_ROLE) 5 | from fcgiproto.events import ( 6 | RequestDataEvent, RequestAbortEvent, RequestBeginEvent, RequestSecondaryDataEvent) 7 | from fcgiproto.exceptions import ProtocolError 8 | from fcgiproto.records import ( 9 | FCGIStdin, FCGIData, FCGIAbortRequest, FCGIStdout, FCGIEndRequest, FCGIBeginRequest, 10 | FCGIParams, encode_name_value_pairs) 11 | from fcgiproto.states import RequestState 12 | 13 | begin_record = FCGIBeginRequest(1, FCGI_RESPONDER, 0) 14 | params_record = FCGIParams(1, encode_name_value_pairs([('NAME', 'VALUE')])) 15 | params_end_record = FCGIParams(1, b'') 16 | stdin_record = FCGIStdin(1, b'') 17 | data_record = FCGIData(1, b'') 18 | abort_record = FCGIAbortRequest(1) 19 | stdout_record = FCGIStdout(1, b'content') 20 | stdout_end_record = FCGIStdout(1, b'') 21 | end_record = FCGIEndRequest(1, 0, FCGI_REQUEST_COMPLETE) 22 | end_record_reject = FCGIEndRequest(1, 0, FCGI_UNKNOWN_ROLE) 23 | 24 | 25 | class BaseStateTests(object): 26 | possible_states = {RequestState.EXPECT_BEGIN_REQUEST, 27 | RequestState.EXPECT_PARAMS, 28 | RequestState.EXPECT_STDIN, 29 | RequestState.EXPECT_DATA, 30 | RequestState.EXPECT_STDOUT, 31 | RequestState.EXPECT_END_REQUEST, 32 | RequestState.FINISHED} 33 | role = None 34 | 35 | @pytest.mark.parametrize('allowed_states, record, expected_event_class', [ 36 | ([RequestState.EXPECT_BEGIN_REQUEST], begin_record, type(None)), 37 | ([RequestState.EXPECT_PARAMS], params_record, type(None)), 38 | ([RequestState.EXPECT_PARAMS], params_end_record, RequestBeginEvent), 39 | ([RequestState.EXPECT_STDIN], stdin_record, RequestDataEvent), 40 | ([RequestState.EXPECT_DATA], data_record, RequestSecondaryDataEvent), 41 | ([RequestState.EXPECT_PARAMS, 42 | RequestState.EXPECT_STDIN, 43 | RequestState.EXPECT_DATA, 44 | RequestState.EXPECT_STDOUT, 45 | RequestState.EXPECT_END_REQUEST], abort_record, RequestAbortEvent), 46 | ([], stdout_record, type(None)) 47 | ], ids=['begin', 'params', 'params_end', 'stdin', 'data', 'abort', 'stdout']) 48 | def test_receive_record(self, allowed_states, record, expected_event_class): 49 | for state_num in sorted(self.possible_states): 50 | state = RequestState() 51 | state.role = self.role 52 | state.state = state_num 53 | state.flags = 0 54 | if state_num in allowed_states: 55 | event = state.receive_record(record) 56 | assert isinstance(event, expected_event_class) 57 | else: 58 | pytest.raises(ProtocolError, state.receive_record, record) 59 | 60 | @pytest.mark.parametrize('allowed_states, record, expected_end_state', [ 61 | ([RequestState.EXPECT_STDOUT], stdout_record, RequestState.EXPECT_STDOUT), 62 | ([RequestState.EXPECT_STDOUT], stdout_end_record, RequestState.EXPECT_END_REQUEST), 63 | ([RequestState.EXPECT_END_REQUEST], end_record, RequestState.FINISHED), 64 | ([RequestState.EXPECT_PARAMS], end_record_reject, RequestState.FINISHED), 65 | ([], stdin_record, RequestState.EXPECT_BEGIN_REQUEST), 66 | ], ids=['stdout', 'stdout_end', 'endrequest', 'rejectrequest', 'stdin']) 67 | def test_send_record(self, allowed_states, record, expected_end_state): 68 | for state_num in sorted(self.possible_states): 69 | state = RequestState() 70 | state.role = self.role 71 | state.state = state_num 72 | state.flags = 0 73 | if state_num in allowed_states: 74 | state.send_record(record) 75 | assert state.state == expected_end_state 76 | else: 77 | pytest.raises(ProtocolError, state.send_record, record) 78 | 79 | 80 | class TestResponder(BaseStateTests): 81 | role = FCGI_RESPONDER 82 | possible_states = BaseStateTests.possible_states - {RequestState.EXPECT_DATA} 83 | 84 | 85 | class TestAuthorizer(BaseStateTests): 86 | role = FCGI_AUTHORIZER 87 | possible_states = (BaseStateTests.possible_states - 88 | {RequestState.EXPECT_STDIN, RequestState.EXPECT_DATA}) 89 | 90 | 91 | class TestFilter(BaseStateTests): 92 | role = FCGI_FILTER 93 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33, py34, py35, py36, flake8, mypy 3 | skip_missing_interpreters = true 4 | 5 | [tox:travis] 6 | 2.7 = py27 7 | 3.3 = py33 8 | 3.4 = py34 9 | 3.5 = py35 10 | 3.6 = py36, flake8, mypy 11 | pypy = pypy 12 | 13 | [testenv] 14 | commands = python -m pytest {posargs} 15 | deps = pytest 16 | pytest-cov 17 | 18 | [testenv:flake8] 19 | basepython = python3.6 20 | deps = flake8 21 | commands = flake8 fcgiproto tests 22 | skip_install = true 23 | 24 | [testenv:mypy] 25 | basepython = python3.6 26 | deps = mypy-lang 27 | commands = mypy -p fcgiproto 28 | skip_install = true 29 | --------------------------------------------------------------------------------