mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-16 14:04:00 +01:00
Initial python3 changes
Mostly just updating libraries, removing string encoding/decoding, fixing some edge cases. No new functionality was added in this commit.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""High-performance, pure-Python HTTP server used by CherryPy."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
try:
|
||||
import pkg_resources
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
__version__ = pkg_resources.get_distribution('cheroot').version
|
||||
except Exception:
|
||||
__version__ = 'unknown'
|
||||
@@ -0,0 +1 @@
|
||||
__version__: str
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Stub for accessing the Cheroot CLI tool."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
# pylint: disable=unused-import
|
||||
"""Compatibility code for using Cheroot with various versions of Python."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
|
||||
import six
|
||||
|
||||
try:
|
||||
import selectors # lgtm [py/unused-import]
|
||||
except ImportError:
|
||||
import selectors2 as selectors # noqa: F401 # lgtm [py/unused-import]
|
||||
|
||||
try:
|
||||
import ssl
|
||||
IS_ABOVE_OPENSSL10 = ssl.OPENSSL_VERSION_INFO >= (1, 1)
|
||||
del ssl
|
||||
except ImportError:
|
||||
IS_ABOVE_OPENSSL10 = None
|
||||
|
||||
# contextlib.suppress was added in Python 3.4
|
||||
try:
|
||||
from contextlib import suppress
|
||||
except ImportError:
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def suppress(*exceptions):
|
||||
"""Return a context manager that suppresses the `exceptions`."""
|
||||
try:
|
||||
yield
|
||||
except exceptions:
|
||||
pass
|
||||
|
||||
|
||||
IS_CI = bool(os.getenv('CI'))
|
||||
IS_GITHUB_ACTIONS_WORKFLOW = bool(os.getenv('GITHUB_WORKFLOW'))
|
||||
|
||||
|
||||
IS_PYPY = platform.python_implementation() == 'PyPy'
|
||||
|
||||
|
||||
SYS_PLATFORM = platform.system()
|
||||
IS_WINDOWS = SYS_PLATFORM == 'Windows'
|
||||
IS_LINUX = SYS_PLATFORM == 'Linux'
|
||||
IS_MACOS = SYS_PLATFORM == 'Darwin'
|
||||
|
||||
PLATFORM_ARCH = platform.machine()
|
||||
IS_PPC = PLATFORM_ARCH.startswith('ppc')
|
||||
|
||||
|
||||
if not six.PY2:
|
||||
def ntob(n, encoding='ISO-8859-1'):
|
||||
"""Return the native string as bytes in the given encoding."""
|
||||
assert_native(n)
|
||||
# In Python 3, the native string type is unicode
|
||||
return n.encode(encoding)
|
||||
|
||||
def ntou(n, encoding='ISO-8859-1'):
|
||||
"""Return the native string as Unicode with the given encoding."""
|
||||
assert_native(n)
|
||||
# In Python 3, the native string type is unicode
|
||||
return n
|
||||
|
||||
def bton(b, encoding='ISO-8859-1'):
|
||||
"""Return the byte string as native string in the given encoding."""
|
||||
return b.decode(encoding)
|
||||
else:
|
||||
# Python 2
|
||||
def ntob(n, encoding='ISO-8859-1'):
|
||||
"""Return the native string as bytes in the given encoding."""
|
||||
assert_native(n)
|
||||
# In Python 2, the native string type is bytes. Assume it's already
|
||||
# in the given encoding, which for ISO-8859-1 is almost always what
|
||||
# was intended.
|
||||
return n
|
||||
|
||||
def ntou(n, encoding='ISO-8859-1'):
|
||||
"""Return the native string as Unicode with the given encoding."""
|
||||
assert_native(n)
|
||||
# In Python 2, the native string type is bytes.
|
||||
# First, check for the special encoding 'escape'. The test suite uses
|
||||
# this to signal that it wants to pass a string with embedded \uXXXX
|
||||
# escapes, but without having to prefix it with u'' for Python 2,
|
||||
# but no prefix for Python 3.
|
||||
if encoding == 'escape':
|
||||
return re.sub(
|
||||
r'\\u([0-9a-zA-Z]{4})',
|
||||
lambda m: six.unichr(int(m.group(1), 16)),
|
||||
n.decode('ISO-8859-1'),
|
||||
)
|
||||
# Assume it's already in the given encoding, which for ISO-8859-1
|
||||
# is almost always what was intended.
|
||||
return n.decode(encoding)
|
||||
|
||||
def bton(b, encoding='ISO-8859-1'):
|
||||
"""Return the byte string as native string in the given encoding."""
|
||||
return b
|
||||
|
||||
|
||||
def assert_native(n):
|
||||
"""Check whether the input is of native :py:class:`str` type.
|
||||
|
||||
Raises:
|
||||
TypeError: in case of failed check
|
||||
|
||||
"""
|
||||
if not isinstance(n, str):
|
||||
raise TypeError('n must be a native str (got %s)' % type(n).__name__)
|
||||
|
||||
|
||||
if not six.PY2:
|
||||
"""Python 3 has :py:class:`memoryview` builtin."""
|
||||
# Python 2.7 has it backported, but socket.write() does
|
||||
# str(memoryview(b'0' * 100)) -> <memory at 0x7fb6913a5588>
|
||||
# instead of accessing it correctly.
|
||||
memoryview = memoryview
|
||||
else:
|
||||
"""Link :py:class:`memoryview` to buffer under Python 2."""
|
||||
memoryview = buffer # noqa: F821
|
||||
|
||||
|
||||
def extract_bytes(mv):
|
||||
r"""Retrieve bytes out of the given input buffer.
|
||||
|
||||
:param mv: input :py:func:`buffer`
|
||||
:type mv: memoryview or bytes
|
||||
|
||||
:return: unwrapped bytes
|
||||
:rtype: bytes
|
||||
|
||||
:raises ValueError: if the input is not one of \
|
||||
:py:class:`memoryview`/:py:func:`buffer` \
|
||||
or :py:class:`bytes`
|
||||
"""
|
||||
if isinstance(mv, memoryview):
|
||||
return bytes(mv) if six.PY2 else mv.tobytes()
|
||||
|
||||
if isinstance(mv, bytes):
|
||||
return mv
|
||||
|
||||
raise ValueError(
|
||||
'extract_bytes() only accepts bytes and memoryview/buffer',
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Command line tool for starting a Cheroot WSGI/HTTP server instance.
|
||||
|
||||
Basic usage:
|
||||
|
||||
.. code-block:: shell-session
|
||||
|
||||
$ # Start a server on 127.0.0.1:8000 with the default settings
|
||||
$ # for the WSGI app myapp/wsgi.py:application()
|
||||
$ cheroot myapp.wsgi
|
||||
|
||||
$ # Start a server on 0.0.0.0:9000 with 8 threads
|
||||
$ # for the WSGI app myapp/wsgi.py:main_app()
|
||||
$ cheroot myapp.wsgi:main_app --bind 0.0.0.0:9000 --threads 8
|
||||
|
||||
$ # Start a server for the cheroot.server.Gateway subclass
|
||||
$ # myapp/gateway.py:HTTPGateway
|
||||
$ cheroot myapp.gateway:HTTPGateway
|
||||
|
||||
$ # Start a server on the UNIX socket /var/spool/myapp.sock
|
||||
$ cheroot myapp.wsgi --bind /var/spool/myapp.sock
|
||||
|
||||
$ # Start a server on the abstract UNIX socket CherootServer
|
||||
$ cheroot myapp.wsgi --bind @CherootServer
|
||||
|
||||
.. spelling::
|
||||
|
||||
cli
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from importlib import import_module
|
||||
import os
|
||||
import sys
|
||||
|
||||
import six
|
||||
|
||||
from . import server
|
||||
from . import wsgi
|
||||
from ._compat import suppress
|
||||
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
class BindLocation:
|
||||
"""A class for storing the bind location for a Cheroot instance."""
|
||||
|
||||
|
||||
class TCPSocket(BindLocation):
|
||||
"""TCPSocket."""
|
||||
|
||||
def __init__(self, address, port):
|
||||
"""Initialize.
|
||||
|
||||
Args:
|
||||
address (str): Host name or IP address
|
||||
port (int): TCP port number
|
||||
|
||||
"""
|
||||
self.bind_addr = address, port
|
||||
|
||||
|
||||
class UnixSocket(BindLocation):
|
||||
"""UnixSocket."""
|
||||
|
||||
def __init__(self, path):
|
||||
"""Initialize."""
|
||||
self.bind_addr = path
|
||||
|
||||
|
||||
class AbstractSocket(BindLocation):
|
||||
"""AbstractSocket."""
|
||||
|
||||
def __init__(self, abstract_socket):
|
||||
"""Initialize."""
|
||||
self.bind_addr = '\x00{sock_path}'.format(sock_path=abstract_socket)
|
||||
|
||||
|
||||
class Application:
|
||||
"""Application."""
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, full_path):
|
||||
"""Read WSGI app/Gateway path string and import application module."""
|
||||
mod_path, _, app_path = full_path.partition(':')
|
||||
app = getattr(import_module(mod_path), app_path or 'application')
|
||||
# suppress the `TypeError` exception, just in case `app` is not a class
|
||||
with suppress(TypeError):
|
||||
if issubclass(app, server.Gateway):
|
||||
return GatewayYo(app)
|
||||
|
||||
return cls(app)
|
||||
|
||||
def __init__(self, wsgi_app):
|
||||
"""Initialize."""
|
||||
if not callable(wsgi_app):
|
||||
raise TypeError(
|
||||
'Application must be a callable object or '
|
||||
'cheroot.server.Gateway subclass',
|
||||
)
|
||||
self.wsgi_app = wsgi_app
|
||||
|
||||
def server_args(self, parsed_args):
|
||||
"""Return keyword args for Server class."""
|
||||
args = {
|
||||
arg: value
|
||||
for arg, value in vars(parsed_args).items()
|
||||
if not arg.startswith('_') and value is not None
|
||||
}
|
||||
args.update(vars(self))
|
||||
return args
|
||||
|
||||
def server(self, parsed_args):
|
||||
"""Server."""
|
||||
return wsgi.Server(**self.server_args(parsed_args))
|
||||
|
||||
|
||||
class GatewayYo:
|
||||
"""Gateway."""
|
||||
|
||||
def __init__(self, gateway):
|
||||
"""Init."""
|
||||
self.gateway = gateway
|
||||
|
||||
def server(self, parsed_args):
|
||||
"""Server."""
|
||||
server_args = vars(self)
|
||||
server_args['bind_addr'] = parsed_args['bind_addr']
|
||||
if parsed_args.max is not None:
|
||||
server_args['maxthreads'] = parsed_args.max
|
||||
if parsed_args.numthreads is not None:
|
||||
server_args['minthreads'] = parsed_args.numthreads
|
||||
return server.HTTPServer(**server_args)
|
||||
|
||||
|
||||
def parse_wsgi_bind_location(bind_addr_string):
|
||||
"""Convert bind address string to a BindLocation."""
|
||||
# if the string begins with an @ symbol, use an abstract socket,
|
||||
# this is the first condition to verify, otherwise the urlparse
|
||||
# validation would detect //@<value> as a valid url with a hostname
|
||||
# with value: "<value>" and port: None
|
||||
if bind_addr_string.startswith('@'):
|
||||
return AbstractSocket(bind_addr_string[1:])
|
||||
|
||||
# try and match for an IP/hostname and port
|
||||
match = six.moves.urllib.parse.urlparse(
|
||||
'//{addr}'.format(addr=bind_addr_string),
|
||||
)
|
||||
try:
|
||||
addr = match.hostname
|
||||
port = match.port
|
||||
if addr is not None or port is not None:
|
||||
return TCPSocket(addr, port)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# else, assume a UNIX socket path
|
||||
return UnixSocket(path=bind_addr_string)
|
||||
|
||||
|
||||
def parse_wsgi_bind_addr(bind_addr_string):
|
||||
"""Convert bind address string to bind address parameter."""
|
||||
return parse_wsgi_bind_location(bind_addr_string).bind_addr
|
||||
|
||||
|
||||
_arg_spec = {
|
||||
'_wsgi_app': {
|
||||
'metavar': 'APP_MODULE',
|
||||
'type': Application.resolve,
|
||||
'help': 'WSGI application callable or cheroot.server.Gateway subclass',
|
||||
},
|
||||
'--bind': {
|
||||
'metavar': 'ADDRESS',
|
||||
'dest': 'bind_addr',
|
||||
'type': parse_wsgi_bind_addr,
|
||||
'default': '[::1]:8000',
|
||||
'help': 'Network interface to listen on (default: [::1]:8000)',
|
||||
},
|
||||
'--chdir': {
|
||||
'metavar': 'PATH',
|
||||
'type': os.chdir,
|
||||
'help': 'Set the working directory',
|
||||
},
|
||||
'--server-name': {
|
||||
'dest': 'server_name',
|
||||
'type': str,
|
||||
'help': 'Web server name to be advertised via Server HTTP header',
|
||||
},
|
||||
'--threads': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'numthreads',
|
||||
'type': int,
|
||||
'help': 'Minimum number of worker threads',
|
||||
},
|
||||
'--max-threads': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'max',
|
||||
'type': int,
|
||||
'help': 'Maximum number of worker threads',
|
||||
},
|
||||
'--timeout': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'timeout',
|
||||
'type': int,
|
||||
'help': 'Timeout in seconds for accepted connections',
|
||||
},
|
||||
'--shutdown-timeout': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'shutdown_timeout',
|
||||
'type': int,
|
||||
'help': 'Time in seconds to wait for worker threads to cleanly exit',
|
||||
},
|
||||
'--request-queue-size': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'request_queue_size',
|
||||
'type': int,
|
||||
'help': 'Maximum number of queued connections',
|
||||
},
|
||||
'--accepted-queue-size': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'accepted_queue_size',
|
||||
'type': int,
|
||||
'help': 'Maximum number of active requests in queue',
|
||||
},
|
||||
'--accepted-queue-timeout': {
|
||||
'metavar': 'INT',
|
||||
'dest': 'accepted_queue_timeout',
|
||||
'type': int,
|
||||
'help': 'Timeout in seconds for putting requests into queue',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Create a new Cheroot instance with arguments from the command line."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Start an instance of the Cheroot WSGI/HTTP server.',
|
||||
)
|
||||
for arg, spec in _arg_spec.items():
|
||||
parser.add_argument(arg, **spec)
|
||||
raw_args = parser.parse_args()
|
||||
|
||||
# ensure cwd in sys.path
|
||||
'' in sys.path or sys.path.insert(0, '')
|
||||
|
||||
# create a server based on the arguments provided
|
||||
raw_args._wsgi_app.server(raw_args).safe_start()
|
||||
@@ -0,0 +1,32 @@
|
||||
from typing import Any
|
||||
|
||||
class BindLocation: ...
|
||||
|
||||
class TCPSocket(BindLocation):
|
||||
bind_addr: Any
|
||||
def __init__(self, address, port) -> None: ...
|
||||
|
||||
class UnixSocket(BindLocation):
|
||||
bind_addr: Any
|
||||
def __init__(self, path) -> None: ...
|
||||
|
||||
class AbstractSocket(BindLocation):
|
||||
bind_addr: Any
|
||||
def __init__(self, abstract_socket) -> None: ...
|
||||
|
||||
class Application:
|
||||
@classmethod
|
||||
def resolve(cls, full_path): ...
|
||||
wsgi_app: Any
|
||||
def __init__(self, wsgi_app) -> None: ...
|
||||
def server_args(self, parsed_args): ...
|
||||
def server(self, parsed_args): ...
|
||||
|
||||
class GatewayYo:
|
||||
gateway: Any
|
||||
def __init__(self, gateway) -> None: ...
|
||||
def server(self, parsed_args): ...
|
||||
|
||||
def parse_wsgi_bind_location(bind_addr_string: str): ...
|
||||
def parse_wsgi_bind_addr(bind_addr_string: str): ...
|
||||
def main() -> None: ...
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Utilities to manage open connections."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import errors
|
||||
from ._compat import selectors
|
||||
from ._compat import suppress
|
||||
from ._compat import IS_WINDOWS
|
||||
from .makefile import MakeFile
|
||||
|
||||
import six
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
try:
|
||||
from ctypes import windll, WinError
|
||||
import ctypes.wintypes
|
||||
_SetHandleInformation = windll.kernel32.SetHandleInformation
|
||||
_SetHandleInformation.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
_SetHandleInformation.restype = ctypes.wintypes.BOOL
|
||||
except ImportError:
|
||||
def prevent_socket_inheritance(sock):
|
||||
"""Stub inheritance prevention.
|
||||
|
||||
Dummy function, since neither fcntl nor ctypes are available.
|
||||
"""
|
||||
pass
|
||||
else:
|
||||
def prevent_socket_inheritance(sock):
|
||||
"""Mark the given socket fd as non-inheritable (Windows)."""
|
||||
if not _SetHandleInformation(sock.fileno(), 1, 0):
|
||||
raise WinError()
|
||||
else:
|
||||
def prevent_socket_inheritance(sock):
|
||||
"""Mark the given socket fd as non-inheritable (POSIX)."""
|
||||
fd = sock.fileno()
|
||||
old_flags = fcntl.fcntl(fd, fcntl.F_GETFD)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC)
|
||||
|
||||
|
||||
class _ThreadsafeSelector:
|
||||
"""Thread-safe wrapper around a DefaultSelector.
|
||||
|
||||
There are 2 thread contexts in which it may be accessed:
|
||||
* the selector thread
|
||||
* one of the worker threads in workers/threadpool.py
|
||||
|
||||
The expected read/write patterns are:
|
||||
* :py:func:`~iter`: selector thread
|
||||
* :py:meth:`register`: selector thread and threadpool,
|
||||
via :py:meth:`~cheroot.workers.threadpool.ThreadPool.put`
|
||||
* :py:meth:`unregister`: selector thread only
|
||||
|
||||
Notably, this means :py:class:`_ThreadsafeSelector` never needs to worry
|
||||
that connections will be removed behind its back.
|
||||
|
||||
The lock is held when iterating or modifying the selector but is not
|
||||
required when :py:meth:`select()ing <selectors.BaseSelector.select>` on it.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._selector = selectors.DefaultSelector()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __len__(self):
|
||||
with self._lock:
|
||||
return len(self._selector.get_map() or {})
|
||||
|
||||
@property
|
||||
def connections(self):
|
||||
"""Retrieve connections registered with the selector."""
|
||||
with self._lock:
|
||||
mapping = self._selector.get_map() or {}
|
||||
for _, (_, sock_fd, _, conn) in mapping.items():
|
||||
yield (sock_fd, conn)
|
||||
|
||||
def register(self, fileobj, events, data=None):
|
||||
"""Register ``fileobj`` with the selector."""
|
||||
with self._lock:
|
||||
return self._selector.register(fileobj, events, data)
|
||||
|
||||
def unregister(self, fileobj):
|
||||
"""Unregister ``fileobj`` from the selector."""
|
||||
with self._lock:
|
||||
return self._selector.unregister(fileobj)
|
||||
|
||||
def select(self, timeout=None):
|
||||
"""Return socket fd and data pairs from selectors.select call.
|
||||
|
||||
Returns entries ready to read in the form:
|
||||
(socket_file_descriptor, connection)
|
||||
"""
|
||||
return (
|
||||
(key.fd, key.data)
|
||||
for key, _ in self._selector.select(timeout=timeout)
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""Close the selector."""
|
||||
with self._lock:
|
||||
self._selector.close()
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Class which manages HTTPConnection objects.
|
||||
|
||||
This is for connections which are being kept-alive for follow-up requests.
|
||||
"""
|
||||
|
||||
def __init__(self, server):
|
||||
"""Initialize ConnectionManager object.
|
||||
|
||||
Args:
|
||||
server (cheroot.server.HTTPServer): web server object
|
||||
that uses this ConnectionManager instance.
|
||||
"""
|
||||
self._serving = False
|
||||
self._stop_requested = False
|
||||
|
||||
self.server = server
|
||||
self._selector = _ThreadsafeSelector()
|
||||
|
||||
self._selector.register(
|
||||
server.socket.fileno(),
|
||||
selectors.EVENT_READ, data=server,
|
||||
)
|
||||
|
||||
def put(self, conn):
|
||||
"""Put idle connection into the ConnectionManager to be managed.
|
||||
|
||||
:param conn: HTTP connection to be managed
|
||||
:type conn: cheroot.server.HTTPConnection
|
||||
"""
|
||||
conn.last_used = time.time()
|
||||
# if this conn doesn't have any more data waiting to be read,
|
||||
# register it with the selector.
|
||||
if conn.rfile.has_data():
|
||||
self.server.process_conn(conn)
|
||||
else:
|
||||
self._selector.register(
|
||||
conn.socket.fileno(), selectors.EVENT_READ, data=conn,
|
||||
)
|
||||
|
||||
def _expire(self, threshold):
|
||||
r"""Expire least recently used connections.
|
||||
|
||||
:param threshold: Connections that have not been used within this \
|
||||
duration (in seconds), are considered expired and \
|
||||
are closed and removed.
|
||||
:type threshold: float
|
||||
|
||||
This should be called periodically.
|
||||
"""
|
||||
# find any connections still registered with the selector
|
||||
# that have not been active recently enough.
|
||||
timed_out_connections = [
|
||||
(sock_fd, conn)
|
||||
for (sock_fd, conn) in self._selector.connections
|
||||
if conn != self.server and conn.last_used < threshold
|
||||
]
|
||||
for sock_fd, conn in timed_out_connections:
|
||||
self._selector.unregister(sock_fd)
|
||||
conn.close()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the selector loop in run() synchronously.
|
||||
|
||||
May take up to half a second.
|
||||
"""
|
||||
self._stop_requested = True
|
||||
while self._serving:
|
||||
time.sleep(0.01)
|
||||
|
||||
def run(self, expiration_interval):
|
||||
"""Run the connections selector indefinitely.
|
||||
|
||||
Args:
|
||||
expiration_interval (float): Interval, in seconds, at which
|
||||
connections will be checked for expiration.
|
||||
|
||||
Connections that are ready to process are submitted via
|
||||
self.server.process_conn()
|
||||
|
||||
Connections submitted for processing must be `put()`
|
||||
back if they should be examined again for another request.
|
||||
|
||||
Can be shut down by calling `stop()`.
|
||||
"""
|
||||
self._serving = True
|
||||
try:
|
||||
self._run(expiration_interval)
|
||||
finally:
|
||||
self._serving = False
|
||||
|
||||
def _run(self, expiration_interval):
|
||||
r"""Run connection handler loop until stop was requested.
|
||||
|
||||
:param expiration_interval: Interval, in seconds, at which \
|
||||
connections will be checked for \
|
||||
expiration.
|
||||
:type expiration_interval: float
|
||||
|
||||
Use ``expiration_interval`` as ``select()`` timeout
|
||||
to assure expired connections are closed in time.
|
||||
|
||||
On Windows cap the timeout to 0.05 seconds
|
||||
as ``select()`` does not return when a socket is ready.
|
||||
"""
|
||||
last_expiration_check = time.time()
|
||||
if IS_WINDOWS:
|
||||
# 0.05 seconds are used as an empirically obtained balance between
|
||||
# max connection delay and idle system load. Benchmarks show a
|
||||
# mean processing time per connection of ~0.03 seconds on Linux
|
||||
# and with 0.01 seconds timeout on Windows:
|
||||
# https://github.com/cherrypy/cheroot/pull/352
|
||||
# While this highly depends on system and hardware, 0.05 seconds
|
||||
# max delay should hence usually not significantly increase the
|
||||
# mean time/delay per connection, but significantly reduce idle
|
||||
# system load by reducing socket loops to 1/5 with 0.01 seconds.
|
||||
select_timeout = min(expiration_interval, 0.05)
|
||||
else:
|
||||
select_timeout = expiration_interval
|
||||
|
||||
while not self._stop_requested:
|
||||
try:
|
||||
active_list = self._selector.select(timeout=select_timeout)
|
||||
except OSError:
|
||||
self._remove_invalid_sockets()
|
||||
continue
|
||||
|
||||
for (sock_fd, conn) in active_list:
|
||||
if conn is self.server:
|
||||
# New connection
|
||||
new_conn = self._from_server_socket(self.server.socket)
|
||||
if new_conn is not None:
|
||||
self.server.process_conn(new_conn)
|
||||
else:
|
||||
# unregister connection from the selector until the server
|
||||
# has read from it and returned it via put()
|
||||
self._selector.unregister(sock_fd)
|
||||
self.server.process_conn(conn)
|
||||
|
||||
now = time.time()
|
||||
if (now - last_expiration_check) > expiration_interval:
|
||||
self._expire(threshold=now - self.server.timeout)
|
||||
last_expiration_check = now
|
||||
|
||||
def _remove_invalid_sockets(self):
|
||||
"""Clean up the resources of any broken connections.
|
||||
|
||||
This method attempts to detect any connections in an invalid state,
|
||||
unregisters them from the selector and closes the file descriptors of
|
||||
the corresponding network sockets where possible.
|
||||
"""
|
||||
invalid_conns = []
|
||||
for sock_fd, conn in self._selector.connections:
|
||||
if conn is self.server:
|
||||
continue
|
||||
|
||||
try:
|
||||
os.fstat(sock_fd)
|
||||
except OSError:
|
||||
invalid_conns.append((sock_fd, conn))
|
||||
|
||||
for sock_fd, conn in invalid_conns:
|
||||
self._selector.unregister(sock_fd)
|
||||
# One of the reason on why a socket could cause an error
|
||||
# is that the socket is already closed, ignore the
|
||||
# socket error if we try to close it at this point.
|
||||
# This is equivalent to OSError in Py3
|
||||
with suppress(socket.error):
|
||||
conn.close()
|
||||
|
||||
def _from_server_socket(self, server_socket): # noqa: C901 # FIXME
|
||||
try:
|
||||
s, addr = server_socket.accept()
|
||||
if self.server.stats['Enabled']:
|
||||
self.server.stats['Accepts'] += 1
|
||||
prevent_socket_inheritance(s)
|
||||
if hasattr(s, 'settimeout'):
|
||||
s.settimeout(self.server.timeout)
|
||||
|
||||
mf = MakeFile
|
||||
ssl_env = {}
|
||||
# if ssl cert and key are set, we try to be a secure HTTP server
|
||||
if self.server.ssl_adapter is not None:
|
||||
try:
|
||||
s, ssl_env = self.server.ssl_adapter.wrap(s)
|
||||
except errors.NoSSLError:
|
||||
msg = (
|
||||
'The client sent a plain HTTP request, but '
|
||||
'this server only speaks HTTPS on this port.'
|
||||
)
|
||||
buf = [
|
||||
'%s 400 Bad Request\r\n' % self.server.protocol,
|
||||
'Content-Length: %s\r\n' % len(msg),
|
||||
'Content-Type: text/plain\r\n\r\n',
|
||||
msg,
|
||||
]
|
||||
|
||||
sock_to_make = s if not six.PY2 else s._sock
|
||||
wfile = mf(sock_to_make, 'wb', io.DEFAULT_BUFFER_SIZE)
|
||||
try:
|
||||
wfile.write(''.join(buf).encode('ISO-8859-1'))
|
||||
except socket.error as ex:
|
||||
if ex.args[0] not in errors.socket_errors_to_ignore:
|
||||
raise
|
||||
return
|
||||
if not s:
|
||||
return
|
||||
mf = self.server.ssl_adapter.makefile
|
||||
# Re-apply our timeout since we may have a new socket object
|
||||
if hasattr(s, 'settimeout'):
|
||||
s.settimeout(self.server.timeout)
|
||||
|
||||
conn = self.server.ConnectionClass(self.server, s, mf)
|
||||
|
||||
if not isinstance(
|
||||
self.server.bind_addr,
|
||||
(six.text_type, six.binary_type),
|
||||
):
|
||||
# optional values
|
||||
# Until we do DNS lookups, omit REMOTE_HOST
|
||||
if addr is None: # sometimes this can happen
|
||||
# figure out if AF_INET or AF_INET6.
|
||||
if len(s.getsockname()) == 2:
|
||||
# AF_INET
|
||||
addr = ('0.0.0.0', 0)
|
||||
else:
|
||||
# AF_INET6
|
||||
addr = ('::', 0)
|
||||
conn.remote_addr = addr[0]
|
||||
conn.remote_port = addr[1]
|
||||
|
||||
conn.ssl_env = ssl_env
|
||||
return conn
|
||||
|
||||
except socket.timeout:
|
||||
# The only reason for the timeout in start() is so we can
|
||||
# notice keyboard interrupts on Win32, which don't interrupt
|
||||
# accept() by default
|
||||
return
|
||||
except socket.error as ex:
|
||||
if self.server.stats['Enabled']:
|
||||
self.server.stats['Socket Errors'] += 1
|
||||
if ex.args[0] in errors.socket_error_eintr:
|
||||
# I *think* this is right. EINTR should occur when a signal
|
||||
# is received during the accept() call; all docs say retry
|
||||
# the call, and I *think* I'm reading it right that Python
|
||||
# will then go ahead and poll for and handle the signal
|
||||
# elsewhere. See
|
||||
# https://github.com/cherrypy/cherrypy/issues/707.
|
||||
return
|
||||
if ex.args[0] in errors.socket_errors_nonblocking:
|
||||
# Just try again. See
|
||||
# https://github.com/cherrypy/cherrypy/issues/479.
|
||||
return
|
||||
if ex.args[0] in errors.socket_errors_to_ignore:
|
||||
# Our socket was closed.
|
||||
# See https://github.com/cherrypy/cherrypy/issues/686.
|
||||
return
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close all monitored connections."""
|
||||
for (_, conn) in self._selector.connections:
|
||||
if conn is not self.server: # server closes its own socket
|
||||
conn.close()
|
||||
self._selector.close()
|
||||
|
||||
@property
|
||||
def _num_connections(self):
|
||||
"""Return the current number of connections.
|
||||
|
||||
Includes all connections registered with the selector,
|
||||
minus one for the server socket, which is always registered
|
||||
with the selector.
|
||||
"""
|
||||
return len(self._selector) - 1
|
||||
|
||||
@property
|
||||
def can_add_keepalive_connection(self):
|
||||
"""Flag whether it is allowed to add a new keep-alive connection."""
|
||||
ka_limit = self.server.keep_alive_conn_limit
|
||||
return ka_limit is None or self._num_connections < ka_limit
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
def prevent_socket_inheritance(sock) -> None: ...
|
||||
|
||||
class _ThreadsafeSelector:
|
||||
def __init__(self) -> None: ...
|
||||
def __len__(self): ...
|
||||
@property
|
||||
def connections(self) -> None: ...
|
||||
def register(self, fileobj, events, data: Any | None = ...): ...
|
||||
def unregister(self, fileobj): ...
|
||||
def select(self, timeout: Any | None = ...): ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
class ConnectionManager:
|
||||
server: Any
|
||||
def __init__(self, server) -> None: ...
|
||||
def put(self, conn) -> None: ...
|
||||
def stop(self) -> None: ...
|
||||
def run(self, expiration_interval) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
@property
|
||||
def can_add_keepalive_connection(self): ...
|
||||
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Collection of exceptions raised and/or processed by Cheroot."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import errno
|
||||
import sys
|
||||
|
||||
|
||||
class MaxSizeExceeded(Exception):
|
||||
"""Exception raised when a client sends more data then acceptable within limit.
|
||||
|
||||
Depends on ``request.body.maxbytes`` config option if used within CherryPy
|
||||
"""
|
||||
|
||||
|
||||
class NoSSLError(Exception):
|
||||
"""Exception raised when a client speaks HTTP to an HTTPS socket."""
|
||||
|
||||
|
||||
class FatalSSLAlert(Exception):
|
||||
"""Exception raised when the SSL implementation signals a fatal alert."""
|
||||
|
||||
|
||||
def plat_specific_errors(*errnames):
|
||||
"""Return error numbers for all errors in ``errnames`` on this platform.
|
||||
|
||||
The :py:mod:`errno` module contains different global constants
|
||||
depending on the specific platform (OS). This function will return
|
||||
the list of numeric values for a given list of potential names.
|
||||
"""
|
||||
missing_attr = {None}
|
||||
unique_nums = {getattr(errno, k, None) for k in errnames}
|
||||
return list(unique_nums - missing_attr)
|
||||
|
||||
|
||||
socket_error_eintr = plat_specific_errors('EINTR', 'WSAEINTR')
|
||||
|
||||
socket_errors_to_ignore = plat_specific_errors(
|
||||
'EPIPE',
|
||||
'EBADF', 'WSAEBADF',
|
||||
'ENOTSOCK', 'WSAENOTSOCK',
|
||||
'ETIMEDOUT', 'WSAETIMEDOUT',
|
||||
'ECONNREFUSED', 'WSAECONNREFUSED',
|
||||
'ECONNRESET', 'WSAECONNRESET',
|
||||
'ECONNABORTED', 'WSAECONNABORTED',
|
||||
'ENETRESET', 'WSAENETRESET',
|
||||
'EHOSTDOWN', 'EHOSTUNREACH',
|
||||
)
|
||||
socket_errors_to_ignore.append('timed out')
|
||||
socket_errors_to_ignore.append('The read operation timed out')
|
||||
socket_errors_nonblocking = plat_specific_errors(
|
||||
'EAGAIN', 'EWOULDBLOCK', 'WSAEWOULDBLOCK',
|
||||
)
|
||||
|
||||
if sys.platform == 'darwin':
|
||||
socket_errors_to_ignore.extend(plat_specific_errors('EPROTOTYPE'))
|
||||
socket_errors_nonblocking.extend(plat_specific_errors('EPROTOTYPE'))
|
||||
|
||||
|
||||
acceptable_sock_shutdown_error_codes = {
|
||||
errno.ENOTCONN,
|
||||
errno.EPIPE, errno.ESHUTDOWN, # corresponds to BrokenPipeError in Python 3
|
||||
errno.ECONNRESET, # corresponds to ConnectionResetError in Python 3
|
||||
}
|
||||
"""Errors that may happen during the connection close sequence.
|
||||
|
||||
* ENOTCONN — client is no longer connected
|
||||
* EPIPE — write on a pipe while the other end has been closed
|
||||
* ESHUTDOWN — write on a socket which has been shutdown for writing
|
||||
* ECONNRESET — connection is reset by the peer, we received a TCP RST packet
|
||||
|
||||
Refs:
|
||||
* https://github.com/cherrypy/cheroot/issues/341#issuecomment-735884889
|
||||
* https://bugs.python.org/issue30319
|
||||
* https://bugs.python.org/issue30329
|
||||
* https://github.com/python/cpython/commit/83a2c28
|
||||
* https://github.com/python/cpython/blob/c39b52f/Lib/poplib.py#L297-L302
|
||||
* https://docs.microsoft.com/windows/win32/api/winsock/nf-winsock-shutdown
|
||||
"""
|
||||
|
||||
try: # py3
|
||||
acceptable_sock_shutdown_exceptions = (
|
||||
BrokenPipeError, ConnectionResetError,
|
||||
)
|
||||
except NameError: # py2
|
||||
acceptable_sock_shutdown_exceptions = ()
|
||||
@@ -0,0 +1,13 @@
|
||||
from typing import Any, List, Set, Tuple
|
||||
|
||||
class MaxSizeExceeded(Exception): ...
|
||||
class NoSSLError(Exception): ...
|
||||
class FatalSSLAlert(Exception): ...
|
||||
|
||||
def plat_specific_errors(*errnames: str) -> List[int]: ...
|
||||
|
||||
socket_error_eintr: List[int]
|
||||
socket_errors_to_ignore: List[int]
|
||||
socket_errors_nonblocking: List[int]
|
||||
acceptable_sock_shutdown_error_codes: Set[int]
|
||||
acceptable_sock_shutdown_exceptions: Tuple[Exception]
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Socket file object."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import socket
|
||||
|
||||
try:
|
||||
# prefer slower Python-based io module
|
||||
import _pyio as io
|
||||
except ImportError:
|
||||
# Python 2.6
|
||||
import io
|
||||
|
||||
import six
|
||||
|
||||
from . import errors
|
||||
from ._compat import extract_bytes, memoryview
|
||||
|
||||
|
||||
# Write only 16K at a time to sockets
|
||||
SOCK_WRITE_BLOCKSIZE = 16384
|
||||
|
||||
|
||||
class BufferedWriter(io.BufferedWriter):
|
||||
"""Faux file object attached to a socket object."""
|
||||
|
||||
def write(self, b):
|
||||
"""Write bytes to buffer."""
|
||||
self._checkClosed()
|
||||
if isinstance(b, str):
|
||||
raise TypeError("can't write str to binary stream")
|
||||
|
||||
with self._write_lock:
|
||||
self._write_buf.extend(b)
|
||||
self._flush_unlocked()
|
||||
return len(b)
|
||||
|
||||
def _flush_unlocked(self):
|
||||
self._checkClosed('flush of closed file')
|
||||
while self._write_buf:
|
||||
try:
|
||||
# ssl sockets only except 'bytes', not bytearrays
|
||||
# so perhaps we should conditionally wrap this for perf?
|
||||
n = self.raw.write(bytes(self._write_buf))
|
||||
except io.BlockingIOError as e:
|
||||
n = e.characters_written
|
||||
del self._write_buf[:n]
|
||||
|
||||
|
||||
class MakeFile_PY2(getattr(socket, '_fileobject', object)):
|
||||
"""Faux file object attached to a socket object."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize faux file object."""
|
||||
self.bytes_read = 0
|
||||
self.bytes_written = 0
|
||||
socket._fileobject.__init__(self, *args, **kwargs)
|
||||
self._refcount = 0
|
||||
|
||||
def _reuse(self):
|
||||
self._refcount += 1
|
||||
|
||||
def _drop(self):
|
||||
if self._refcount < 0:
|
||||
self.close()
|
||||
else:
|
||||
self._refcount -= 1
|
||||
|
||||
def write(self, data):
|
||||
"""Send entire data contents for non-blocking sockets."""
|
||||
bytes_sent = 0
|
||||
data_mv = memoryview(data)
|
||||
payload_size = len(data_mv)
|
||||
while bytes_sent < payload_size:
|
||||
try:
|
||||
bytes_sent += self.send(
|
||||
data_mv[bytes_sent:bytes_sent + SOCK_WRITE_BLOCKSIZE],
|
||||
)
|
||||
except socket.error as e:
|
||||
if e.args[0] not in errors.socket_errors_nonblocking:
|
||||
raise
|
||||
|
||||
def send(self, data):
|
||||
"""Send some part of message to the socket."""
|
||||
bytes_sent = self._sock.send(extract_bytes(data))
|
||||
self.bytes_written += bytes_sent
|
||||
return bytes_sent
|
||||
|
||||
def flush(self):
|
||||
"""Write all data from buffer to socket and reset write buffer."""
|
||||
if self._wbuf:
|
||||
buffer = ''.join(self._wbuf)
|
||||
self._wbuf = []
|
||||
self.write(buffer)
|
||||
|
||||
def recv(self, size):
|
||||
"""Receive message of a size from the socket."""
|
||||
while True:
|
||||
try:
|
||||
data = self._sock.recv(size)
|
||||
self.bytes_read += len(data)
|
||||
return data
|
||||
except socket.error as e:
|
||||
what = (
|
||||
e.args[0] not in errors.socket_errors_nonblocking
|
||||
and e.args[0] not in errors.socket_error_eintr
|
||||
)
|
||||
if what:
|
||||
raise
|
||||
|
||||
class FauxSocket:
|
||||
"""Faux socket with the minimal interface required by pypy."""
|
||||
|
||||
def _reuse(self):
|
||||
pass
|
||||
|
||||
_fileobject_uses_str_type = six.PY2 and isinstance(
|
||||
socket._fileobject(FauxSocket())._rbuf, six.string_types,
|
||||
)
|
||||
|
||||
# FauxSocket is no longer needed
|
||||
del FauxSocket
|
||||
|
||||
if not _fileobject_uses_str_type: # noqa: C901 # FIXME
|
||||
def read(self, size=-1):
|
||||
"""Read data from the socket to buffer."""
|
||||
# Use max, disallow tiny reads in a loop as they are very
|
||||
# inefficient.
|
||||
# We never leave read() with any leftover data from a new recv()
|
||||
# call in our internal buffer.
|
||||
rbufsize = max(self._rbufsize, self.default_bufsize)
|
||||
# Our use of StringIO rather than lists of string objects returned
|
||||
# by recv() minimizes memory usage and fragmentation that occurs
|
||||
# when rbufsize is large compared to the typical return value of
|
||||
# recv().
|
||||
buf = self._rbuf
|
||||
buf.seek(0, 2) # seek end
|
||||
if size < 0:
|
||||
# Read until EOF
|
||||
# reset _rbuf. we consume it via buf.
|
||||
self._rbuf = io.BytesIO()
|
||||
while True:
|
||||
data = self.recv(rbufsize)
|
||||
if not data:
|
||||
break
|
||||
buf.write(data)
|
||||
return buf.getvalue()
|
||||
else:
|
||||
# Read until size bytes or EOF seen, whichever comes first
|
||||
buf_len = buf.tell()
|
||||
if buf_len >= size:
|
||||
# Already have size bytes in our buffer? Extract and
|
||||
# return.
|
||||
buf.seek(0)
|
||||
rv = buf.read(size)
|
||||
self._rbuf = io.BytesIO()
|
||||
self._rbuf.write(buf.read())
|
||||
return rv
|
||||
|
||||
# reset _rbuf. we consume it via buf.
|
||||
self._rbuf = io.BytesIO()
|
||||
while True:
|
||||
left = size - buf_len
|
||||
# recv() will malloc the amount of memory given as its
|
||||
# parameter even though it often returns much less data
|
||||
# than that. The returned data string is short lived
|
||||
# as we copy it into a StringIO and free it. This avoids
|
||||
# fragmentation issues on many platforms.
|
||||
data = self.recv(left)
|
||||
if not data:
|
||||
break
|
||||
n = len(data)
|
||||
if n == size and not buf_len:
|
||||
# Shortcut. Avoid buffer data copies when:
|
||||
# - We have no data in our buffer.
|
||||
# AND
|
||||
# - Our call to recv returned exactly the
|
||||
# number of bytes we were asked to read.
|
||||
return data
|
||||
if n == left:
|
||||
buf.write(data)
|
||||
del data # explicit free
|
||||
break
|
||||
assert n <= left, 'recv(%d) returned %d bytes' % (left, n)
|
||||
buf.write(data)
|
||||
buf_len += n
|
||||
del data # explicit free
|
||||
# assert buf_len == buf.tell()
|
||||
return buf.getvalue()
|
||||
|
||||
def readline(self, size=-1):
|
||||
"""Read line from the socket to buffer."""
|
||||
buf = self._rbuf
|
||||
buf.seek(0, 2) # seek end
|
||||
if buf.tell() > 0:
|
||||
# check if we already have it in our buffer
|
||||
buf.seek(0)
|
||||
bline = buf.readline(size)
|
||||
if bline.endswith('\n') or len(bline) == size:
|
||||
self._rbuf = io.BytesIO()
|
||||
self._rbuf.write(buf.read())
|
||||
return bline
|
||||
del bline
|
||||
if size < 0:
|
||||
# Read until \n or EOF, whichever comes first
|
||||
if self._rbufsize <= 1:
|
||||
# Speed up unbuffered case
|
||||
buf.seek(0)
|
||||
buffers = [buf.read()]
|
||||
# reset _rbuf. we consume it via buf.
|
||||
self._rbuf = io.BytesIO()
|
||||
data = None
|
||||
recv = self.recv
|
||||
while data != '\n':
|
||||
data = recv(1)
|
||||
if not data:
|
||||
break
|
||||
buffers.append(data)
|
||||
return ''.join(buffers)
|
||||
|
||||
buf.seek(0, 2) # seek end
|
||||
# reset _rbuf. we consume it via buf.
|
||||
self._rbuf = io.BytesIO()
|
||||
while True:
|
||||
data = self.recv(self._rbufsize)
|
||||
if not data:
|
||||
break
|
||||
nl = data.find('\n')
|
||||
if nl >= 0:
|
||||
nl += 1
|
||||
buf.write(data[:nl])
|
||||
self._rbuf.write(data[nl:])
|
||||
del data
|
||||
break
|
||||
buf.write(data)
|
||||
return buf.getvalue()
|
||||
|
||||
else:
|
||||
# Read until size bytes or \n or EOF seen, whichever comes
|
||||
# first
|
||||
buf.seek(0, 2) # seek end
|
||||
buf_len = buf.tell()
|
||||
if buf_len >= size:
|
||||
buf.seek(0)
|
||||
rv = buf.read(size)
|
||||
self._rbuf = io.BytesIO()
|
||||
self._rbuf.write(buf.read())
|
||||
return rv
|
||||
# reset _rbuf. we consume it via buf.
|
||||
self._rbuf = io.BytesIO()
|
||||
while True:
|
||||
data = self.recv(self._rbufsize)
|
||||
if not data:
|
||||
break
|
||||
left = size - buf_len
|
||||
# did we just receive a newline?
|
||||
nl = data.find('\n', 0, left)
|
||||
if nl >= 0:
|
||||
nl += 1
|
||||
# save the excess data to _rbuf
|
||||
self._rbuf.write(data[nl:])
|
||||
if buf_len:
|
||||
buf.write(data[:nl])
|
||||
break
|
||||
else:
|
||||
# Shortcut. Avoid data copy through buf when
|
||||
# returning a substring of our first recv().
|
||||
return data[:nl]
|
||||
n = len(data)
|
||||
if n == size and not buf_len:
|
||||
# Shortcut. Avoid data copy through buf when
|
||||
# returning exactly all of our first recv().
|
||||
return data
|
||||
if n >= left:
|
||||
buf.write(data[:left])
|
||||
self._rbuf.write(data[left:])
|
||||
break
|
||||
buf.write(data)
|
||||
buf_len += n
|
||||
# assert buf_len == buf.tell()
|
||||
return buf.getvalue()
|
||||
|
||||
def has_data(self):
|
||||
"""Return true if there is buffered data to read."""
|
||||
return bool(self._rbuf.getvalue())
|
||||
|
||||
else:
|
||||
def read(self, size=-1):
|
||||
"""Read data from the socket to buffer."""
|
||||
if size < 0:
|
||||
# Read until EOF
|
||||
buffers = [self._rbuf]
|
||||
self._rbuf = ''
|
||||
if self._rbufsize <= 1:
|
||||
recv_size = self.default_bufsize
|
||||
else:
|
||||
recv_size = self._rbufsize
|
||||
|
||||
while True:
|
||||
data = self.recv(recv_size)
|
||||
if not data:
|
||||
break
|
||||
buffers.append(data)
|
||||
return ''.join(buffers)
|
||||
else:
|
||||
# Read until size bytes or EOF seen, whichever comes first
|
||||
data = self._rbuf
|
||||
buf_len = len(data)
|
||||
if buf_len >= size:
|
||||
self._rbuf = data[size:]
|
||||
return data[:size]
|
||||
buffers = []
|
||||
if data:
|
||||
buffers.append(data)
|
||||
self._rbuf = ''
|
||||
while True:
|
||||
left = size - buf_len
|
||||
recv_size = max(self._rbufsize, left)
|
||||
data = self.recv(recv_size)
|
||||
if not data:
|
||||
break
|
||||
buffers.append(data)
|
||||
n = len(data)
|
||||
if n >= left:
|
||||
self._rbuf = data[left:]
|
||||
buffers[-1] = data[:left]
|
||||
break
|
||||
buf_len += n
|
||||
return ''.join(buffers)
|
||||
|
||||
def readline(self, size=-1):
|
||||
"""Read line from the socket to buffer."""
|
||||
data = self._rbuf
|
||||
if size < 0:
|
||||
# Read until \n or EOF, whichever comes first
|
||||
if self._rbufsize <= 1:
|
||||
# Speed up unbuffered case
|
||||
assert data == ''
|
||||
buffers = []
|
||||
while data != '\n':
|
||||
data = self.recv(1)
|
||||
if not data:
|
||||
break
|
||||
buffers.append(data)
|
||||
return ''.join(buffers)
|
||||
nl = data.find('\n')
|
||||
if nl >= 0:
|
||||
nl += 1
|
||||
self._rbuf = data[nl:]
|
||||
return data[:nl]
|
||||
buffers = []
|
||||
if data:
|
||||
buffers.append(data)
|
||||
self._rbuf = ''
|
||||
while True:
|
||||
data = self.recv(self._rbufsize)
|
||||
if not data:
|
||||
break
|
||||
buffers.append(data)
|
||||
nl = data.find('\n')
|
||||
if nl >= 0:
|
||||
nl += 1
|
||||
self._rbuf = data[nl:]
|
||||
buffers[-1] = data[:nl]
|
||||
break
|
||||
return ''.join(buffers)
|
||||
else:
|
||||
# Read until size bytes or \n or EOF seen, whichever comes
|
||||
# first
|
||||
nl = data.find('\n', 0, size)
|
||||
if nl >= 0:
|
||||
nl += 1
|
||||
self._rbuf = data[nl:]
|
||||
return data[:nl]
|
||||
buf_len = len(data)
|
||||
if buf_len >= size:
|
||||
self._rbuf = data[size:]
|
||||
return data[:size]
|
||||
buffers = []
|
||||
if data:
|
||||
buffers.append(data)
|
||||
self._rbuf = ''
|
||||
while True:
|
||||
data = self.recv(self._rbufsize)
|
||||
if not data:
|
||||
break
|
||||
buffers.append(data)
|
||||
left = size - buf_len
|
||||
nl = data.find('\n', 0, left)
|
||||
if nl >= 0:
|
||||
nl += 1
|
||||
self._rbuf = data[nl:]
|
||||
buffers[-1] = data[:nl]
|
||||
break
|
||||
n = len(data)
|
||||
if n >= left:
|
||||
self._rbuf = data[left:]
|
||||
buffers[-1] = data[:left]
|
||||
break
|
||||
buf_len += n
|
||||
return ''.join(buffers)
|
||||
|
||||
def has_data(self):
|
||||
"""Return true if there is buffered data to read."""
|
||||
return bool(self._rbuf)
|
||||
|
||||
|
||||
if not six.PY2:
|
||||
class StreamReader(io.BufferedReader):
|
||||
"""Socket stream reader."""
|
||||
|
||||
def __init__(self, sock, mode='r', bufsize=io.DEFAULT_BUFFER_SIZE):
|
||||
"""Initialize socket stream reader."""
|
||||
super().__init__(socket.SocketIO(sock, mode), bufsize)
|
||||
self.bytes_read = 0
|
||||
|
||||
def read(self, *args, **kwargs):
|
||||
"""Capture bytes read."""
|
||||
val = super().read(*args, **kwargs)
|
||||
self.bytes_read += len(val)
|
||||
return val
|
||||
|
||||
def has_data(self):
|
||||
"""Return true if there is buffered data to read."""
|
||||
return len(self._read_buf) > self._read_pos
|
||||
|
||||
class StreamWriter(BufferedWriter):
|
||||
"""Socket stream writer."""
|
||||
|
||||
def __init__(self, sock, mode='w', bufsize=io.DEFAULT_BUFFER_SIZE):
|
||||
"""Initialize socket stream writer."""
|
||||
super().__init__(socket.SocketIO(sock, mode), bufsize)
|
||||
self.bytes_written = 0
|
||||
|
||||
def write(self, val, *args, **kwargs):
|
||||
"""Capture bytes written."""
|
||||
res = super().write(val, *args, **kwargs)
|
||||
self.bytes_written += len(val)
|
||||
return res
|
||||
|
||||
def MakeFile(sock, mode='r', bufsize=io.DEFAULT_BUFFER_SIZE):
|
||||
"""File object attached to a socket object."""
|
||||
cls = StreamReader if 'r' in mode else StreamWriter
|
||||
return cls(sock, mode, bufsize)
|
||||
else:
|
||||
StreamReader = StreamWriter = MakeFile = MakeFile_PY2
|
||||
@@ -0,0 +1,32 @@
|
||||
import io
|
||||
|
||||
SOCK_WRITE_BLOCKSIZE: int
|
||||
|
||||
class BufferedWriter(io.BufferedWriter):
|
||||
def write(self, b): ...
|
||||
|
||||
class MakeFile_PY2:
|
||||
bytes_read: int
|
||||
bytes_written: int
|
||||
def __init__(self, *args, **kwargs) -> None: ...
|
||||
def write(self, data) -> None: ...
|
||||
def send(self, data): ...
|
||||
def flush(self) -> None: ...
|
||||
def recv(self, size): ...
|
||||
class FauxSocket: ...
|
||||
def read(self, size: int = ...): ...
|
||||
def readline(self, size: int = ...): ...
|
||||
def has_data(self): ...
|
||||
|
||||
class StreamReader(io.BufferedReader):
|
||||
bytes_read: int
|
||||
def __init__(self, sock, mode: str = ..., bufsize=...) -> None: ...
|
||||
def read(self, *args, **kwargs): ...
|
||||
def has_data(self): ...
|
||||
|
||||
class StreamWriter(BufferedWriter):
|
||||
bytes_written: int
|
||||
def __init__(self, sock, mode: str = ..., bufsize=...) -> None: ...
|
||||
def write(self, val, *args, **kwargs): ...
|
||||
|
||||
def MakeFile(sock, mode: str = ..., bufsize=...): ...
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
from typing import Any
|
||||
|
||||
class HeaderReader:
|
||||
def __call__(self, rfile, hdict: Any | None = ...): ...
|
||||
|
||||
class DropUnderscoreHeaderReader(HeaderReader): ...
|
||||
|
||||
class SizeCheckWrapper:
|
||||
rfile: Any
|
||||
maxlen: Any
|
||||
bytes_read: int
|
||||
def __init__(self, rfile, maxlen) -> None: ...
|
||||
def read(self, size: Any | None = ...): ...
|
||||
def readline(self, size: Any | None = ...): ...
|
||||
def readlines(self, sizehint: int = ...): ...
|
||||
def close(self) -> None: ...
|
||||
def __iter__(self): ...
|
||||
def __next__(self): ...
|
||||
next: Any
|
||||
|
||||
class KnownLengthRFile:
|
||||
rfile: Any
|
||||
remaining: Any
|
||||
def __init__(self, rfile, content_length) -> None: ...
|
||||
def read(self, size: Any | None = ...): ...
|
||||
def readline(self, size: Any | None = ...): ...
|
||||
def readlines(self, sizehint: int = ...): ...
|
||||
def close(self) -> None: ...
|
||||
def __iter__(self): ...
|
||||
def __next__(self): ...
|
||||
next: Any
|
||||
|
||||
class ChunkedRFile:
|
||||
rfile: Any
|
||||
maxlen: Any
|
||||
bytes_read: int
|
||||
buffer: Any
|
||||
bufsize: Any
|
||||
closed: bool
|
||||
def __init__(self, rfile, maxlen, bufsize: int = ...) -> None: ...
|
||||
def read(self, size: Any | None = ...): ...
|
||||
def readline(self, size: Any | None = ...): ...
|
||||
def readlines(self, sizehint: int = ...): ...
|
||||
def read_trailer_lines(self) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
class HTTPRequest:
|
||||
server: Any
|
||||
conn: Any
|
||||
inheaders: Any
|
||||
outheaders: Any
|
||||
ready: bool
|
||||
close_connection: bool
|
||||
chunked_write: bool
|
||||
header_reader: Any
|
||||
started_request: bool
|
||||
scheme: bytes
|
||||
response_protocol: str
|
||||
status: str
|
||||
sent_headers: bool
|
||||
chunked_read: bool
|
||||
proxy_mode: Any
|
||||
strict_mode: Any
|
||||
def __init__(self, server, conn, proxy_mode: bool = ..., strict_mode: bool = ...) -> None: ...
|
||||
rfile: Any
|
||||
def parse_request(self) -> None: ...
|
||||
uri: Any
|
||||
method: Any
|
||||
authority: Any
|
||||
path: Any
|
||||
qs: Any
|
||||
request_protocol: Any
|
||||
def read_request_line(self): ...
|
||||
def read_request_headers(self): ...
|
||||
def respond(self) -> None: ...
|
||||
def simple_response(self, status, msg: str = ...) -> None: ...
|
||||
def ensure_headers_sent(self) -> None: ...
|
||||
def write(self, chunk) -> None: ...
|
||||
def send_headers(self) -> None: ...
|
||||
|
||||
class HTTPConnection:
|
||||
remote_addr: Any
|
||||
remote_port: Any
|
||||
ssl_env: Any
|
||||
rbufsize: Any
|
||||
wbufsize: Any
|
||||
RequestHandlerClass: Any
|
||||
peercreds_enabled: bool
|
||||
peercreds_resolve_enabled: bool
|
||||
last_used: Any
|
||||
server: Any
|
||||
socket: Any
|
||||
rfile: Any
|
||||
wfile: Any
|
||||
requests_seen: int
|
||||
def __init__(self, server, sock, makefile=...) -> None: ...
|
||||
def communicate(self): ...
|
||||
linger: bool
|
||||
def close(self) -> None: ...
|
||||
def get_peer_creds(self): ...
|
||||
@property
|
||||
def peer_pid(self): ...
|
||||
@property
|
||||
def peer_uid(self): ...
|
||||
@property
|
||||
def peer_gid(self): ...
|
||||
def resolve_peer_creds(self): ...
|
||||
@property
|
||||
def peer_user(self): ...
|
||||
@property
|
||||
def peer_group(self): ...
|
||||
|
||||
class HTTPServer:
|
||||
gateway: Any
|
||||
minthreads: Any
|
||||
maxthreads: Any
|
||||
server_name: Any
|
||||
protocol: str
|
||||
request_queue_size: int
|
||||
shutdown_timeout: int
|
||||
timeout: int
|
||||
expiration_interval: float
|
||||
version: Any
|
||||
software: Any
|
||||
ready: bool
|
||||
max_request_header_size: int
|
||||
max_request_body_size: int
|
||||
nodelay: bool
|
||||
ConnectionClass: Any
|
||||
ssl_adapter: Any
|
||||
peercreds_enabled: bool
|
||||
peercreds_resolve_enabled: bool
|
||||
keep_alive_conn_limit: int
|
||||
requests: Any
|
||||
def __init__(self, bind_addr, gateway, minthreads: int = ..., maxthreads: int = ..., server_name: Any | None = ..., peercreds_enabled: bool = ..., peercreds_resolve_enabled: bool = ...) -> None: ...
|
||||
stats: Any
|
||||
def clear_stats(self): ...
|
||||
def runtime(self): ...
|
||||
@property
|
||||
def bind_addr(self): ...
|
||||
@bind_addr.setter
|
||||
def bind_addr(self, value) -> None: ...
|
||||
def safe_start(self) -> None: ...
|
||||
socket: Any
|
||||
def prepare(self) -> None: ...
|
||||
def serve(self) -> None: ...
|
||||
def start(self) -> None: ...
|
||||
@property
|
||||
def can_add_keepalive_connection(self): ...
|
||||
def put_conn(self, conn) -> None: ...
|
||||
def error_log(self, msg: str = ..., level: int = ..., traceback: bool = ...) -> None: ...
|
||||
def bind(self, family, type, proto: int = ...): ...
|
||||
def bind_unix_socket(self, bind_addr): ...
|
||||
@staticmethod
|
||||
def prepare_socket(bind_addr, family, type, proto, nodelay, ssl_adapter): ...
|
||||
@staticmethod
|
||||
def bind_socket(socket_, bind_addr): ...
|
||||
@staticmethod
|
||||
def resolve_real_bind_addr(socket_): ...
|
||||
def process_conn(self, conn) -> None: ...
|
||||
@property
|
||||
def interrupt(self): ...
|
||||
@interrupt.setter
|
||||
def interrupt(self, interrupt) -> None: ...
|
||||
def stop(self) -> None: ...
|
||||
|
||||
class Gateway:
|
||||
req: Any
|
||||
def __init__(self, req) -> None: ...
|
||||
def respond(self) -> None: ...
|
||||
|
||||
def get_ssl_adapter_class(name: str = ...): ...
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Implementation of the SSL adapter base interface."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
from six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(ABCMeta)
|
||||
class Adapter:
|
||||
"""Base class for SSL driver library adapters.
|
||||
|
||||
Required methods:
|
||||
|
||||
* ``wrap(sock) -> (wrapped socket, ssl environ dict)``
|
||||
* ``makefile(sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE) ->
|
||||
socket file object``
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self, certificate, private_key, certificate_chain=None,
|
||||
ciphers=None,
|
||||
):
|
||||
"""Set up certificates, private key ciphers and reset context."""
|
||||
self.certificate = certificate
|
||||
self.private_key = private_key
|
||||
self.certificate_chain = certificate_chain
|
||||
self.ciphers = ciphers
|
||||
self.context = None
|
||||
|
||||
@abstractmethod
|
||||
def bind(self, sock):
|
||||
"""Wrap and return the given socket."""
|
||||
return sock
|
||||
|
||||
@abstractmethod
|
||||
def wrap(self, sock):
|
||||
"""Wrap and return the given socket, plus WSGI environ entries."""
|
||||
raise NotImplementedError # pragma: no cover
|
||||
|
||||
@abstractmethod
|
||||
def get_environ(self):
|
||||
"""Return WSGI environ entries to be merged into each request."""
|
||||
raise NotImplementedError # pragma: no cover
|
||||
|
||||
@abstractmethod
|
||||
def makefile(self, sock, mode='r', bufsize=-1):
|
||||
"""Return socket file object."""
|
||||
raise NotImplementedError # pragma: no cover
|
||||
@@ -0,0 +1,19 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
class Adapter():
|
||||
certificate: Any
|
||||
private_key: Any
|
||||
certificate_chain: Any
|
||||
ciphers: Any
|
||||
context: Any
|
||||
@abstractmethod
|
||||
def __init__(self, certificate, private_key, certificate_chain: Any | None = ..., ciphers: Any | None = ...): ...
|
||||
@abstractmethod
|
||||
def bind(self, sock): ...
|
||||
@abstractmethod
|
||||
def wrap(self, sock): ...
|
||||
@abstractmethod
|
||||
def get_environ(self): ...
|
||||
@abstractmethod
|
||||
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
A library for integrating Python's builtin :py:mod:`ssl` library with Cheroot.
|
||||
|
||||
The :py:mod:`ssl` module must be importable for SSL functionality.
|
||||
|
||||
To use this module, set ``HTTPServer.ssl_adapter`` to an instance of
|
||||
``BuiltinSSLAdapter``.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
try:
|
||||
import ssl
|
||||
except ImportError:
|
||||
ssl = None
|
||||
|
||||
try:
|
||||
from _pyio import DEFAULT_BUFFER_SIZE
|
||||
except ImportError:
|
||||
try:
|
||||
from io import DEFAULT_BUFFER_SIZE
|
||||
except ImportError:
|
||||
DEFAULT_BUFFER_SIZE = -1
|
||||
|
||||
import six
|
||||
|
||||
from . import Adapter
|
||||
from .. import errors
|
||||
from .._compat import IS_ABOVE_OPENSSL10, suppress
|
||||
from ..makefile import StreamReader, StreamWriter
|
||||
from ..server import HTTPServer
|
||||
|
||||
if six.PY2:
|
||||
generic_socket_error = socket.error
|
||||
else:
|
||||
generic_socket_error = OSError
|
||||
|
||||
|
||||
def _assert_ssl_exc_contains(exc, *msgs):
|
||||
"""Check whether SSL exception contains either of messages provided."""
|
||||
if len(msgs) < 1:
|
||||
raise TypeError(
|
||||
'_assert_ssl_exc_contains() requires '
|
||||
'at least one message to be passed.',
|
||||
)
|
||||
err_msg_lower = str(exc).lower()
|
||||
return any(m.lower() in err_msg_lower for m in msgs)
|
||||
|
||||
|
||||
def _loopback_for_cert_thread(context, server):
|
||||
"""Wrap a socket in ssl and perform the server-side handshake."""
|
||||
# As we only care about parsing the certificate, the failure of
|
||||
# which will cause an exception in ``_loopback_for_cert``,
|
||||
# we can safely ignore connection and ssl related exceptions. Ref:
|
||||
# https://github.com/cherrypy/cheroot/issues/302#issuecomment-662592030
|
||||
with suppress(ssl.SSLError, OSError):
|
||||
with context.wrap_socket(
|
||||
server, do_handshake_on_connect=True, server_side=True,
|
||||
) as ssl_sock:
|
||||
# in TLS 1.3 (Python 3.7+, OpenSSL 1.1.1+), the server
|
||||
# sends the client session tickets that can be used to
|
||||
# resume the TLS session on a new connection without
|
||||
# performing the full handshake again. session tickets are
|
||||
# sent as a post-handshake message at some _unspecified_
|
||||
# time and thus a successful connection may be closed
|
||||
# without the client having received the tickets.
|
||||
# Unfortunately, on Windows (Python 3.8+), this is treated
|
||||
# as an incomplete handshake on the server side and a
|
||||
# ``ConnectionAbortedError`` is raised.
|
||||
# TLS 1.3 support is still incomplete in Python 3.8;
|
||||
# there is no way for the client to wait for tickets.
|
||||
# While not necessary for retrieving the parsed certificate,
|
||||
# we send a tiny bit of data over the connection in an
|
||||
# attempt to give the server a chance to send the session
|
||||
# tickets and close the connection cleanly.
|
||||
# Note that, as this is essentially a race condition,
|
||||
# the error may still occur ocasionally.
|
||||
ssl_sock.send(b'0000')
|
||||
|
||||
|
||||
def _loopback_for_cert(certificate, private_key, certificate_chain):
|
||||
"""Create a loopback connection to parse a cert with a private key."""
|
||||
context = ssl.create_default_context(cafile=certificate_chain)
|
||||
context.load_cert_chain(certificate, private_key)
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Python 3+ Unix, Python 3.5+ Windows
|
||||
client, server = socket.socketpair()
|
||||
try:
|
||||
# `wrap_socket` will block until the ssl handshake is complete.
|
||||
# it must be called on both ends at the same time -> thread
|
||||
# openssl will cache the peer's cert during a successful handshake
|
||||
# and return it via `getpeercert` even after the socket is closed.
|
||||
# when `close` is called, the SSL shutdown notice will be sent
|
||||
# and then python will wait to receive the corollary shutdown.
|
||||
thread = threading.Thread(
|
||||
target=_loopback_for_cert_thread, args=(context, server),
|
||||
)
|
||||
try:
|
||||
thread.start()
|
||||
with context.wrap_socket(
|
||||
client, do_handshake_on_connect=True,
|
||||
server_side=False,
|
||||
) as ssl_sock:
|
||||
ssl_sock.recv(4)
|
||||
return ssl_sock.getpeercert()
|
||||
finally:
|
||||
thread.join()
|
||||
finally:
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
|
||||
def _parse_cert(certificate, private_key, certificate_chain):
|
||||
"""Parse a certificate."""
|
||||
# loopback_for_cert uses socket.socketpair which was only
|
||||
# introduced in Python 3.0 for *nix and 3.5 for Windows
|
||||
# and requires OS support (AttributeError, OSError)
|
||||
# it also requires a private key either in its own file
|
||||
# or combined with the cert (SSLError)
|
||||
with suppress(AttributeError, ssl.SSLError, OSError):
|
||||
return _loopback_for_cert(certificate, private_key, certificate_chain)
|
||||
|
||||
# KLUDGE: using an undocumented, private, test method to parse a cert
|
||||
# unfortunately, it is the only built-in way without a connection
|
||||
# as a private, undocumented method, it may change at any time
|
||||
# so be tolerant of *any* possible errors it may raise
|
||||
with suppress(Exception):
|
||||
return ssl._ssl._test_decode_cert(certificate)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _sni_callback(sock, sni, context):
|
||||
"""Handle the SNI callback to tag the socket with the SNI."""
|
||||
sock.sni = sni
|
||||
# return None to allow the TLS negotiation to continue
|
||||
|
||||
|
||||
class BuiltinSSLAdapter(Adapter):
|
||||
"""Wrapper for integrating Python's builtin :py:mod:`ssl` with Cheroot."""
|
||||
|
||||
certificate = None
|
||||
"""The file name of the server SSL certificate."""
|
||||
|
||||
private_key = None
|
||||
"""The file name of the server's private key file."""
|
||||
|
||||
certificate_chain = None
|
||||
"""The file name of the certificate chain file."""
|
||||
|
||||
ciphers = None
|
||||
"""The ciphers list of SSL."""
|
||||
|
||||
# from mod_ssl/pkg.sslmod/ssl_engine_vars.c ssl_var_lookup_ssl_cert
|
||||
CERT_KEY_TO_ENV = {
|
||||
'version': 'M_VERSION',
|
||||
'serialNumber': 'M_SERIAL',
|
||||
'notBefore': 'V_START',
|
||||
'notAfter': 'V_END',
|
||||
'subject': 'S_DN',
|
||||
'issuer': 'I_DN',
|
||||
'subjectAltName': 'SAN',
|
||||
# not parsed by the Python standard library
|
||||
# - A_SIG
|
||||
# - A_KEY
|
||||
# not provided by mod_ssl
|
||||
# - OCSP
|
||||
# - caIssuers
|
||||
# - crlDistributionPoints
|
||||
}
|
||||
|
||||
# from mod_ssl/pkg.sslmod/ssl_engine_vars.c ssl_var_lookup_ssl_cert_dn_rec
|
||||
CERT_KEY_TO_LDAP_CODE = {
|
||||
'countryName': 'C',
|
||||
'stateOrProvinceName': 'ST',
|
||||
# NOTE: mod_ssl also provides 'stateOrProvinceName' as 'SP'
|
||||
# for compatibility with SSLeay
|
||||
'localityName': 'L',
|
||||
'organizationName': 'O',
|
||||
'organizationalUnitName': 'OU',
|
||||
'commonName': 'CN',
|
||||
'title': 'T',
|
||||
'initials': 'I',
|
||||
'givenName': 'G',
|
||||
'surname': 'S',
|
||||
'description': 'D',
|
||||
'userid': 'UID',
|
||||
'emailAddress': 'Email',
|
||||
# not provided by mod_ssl
|
||||
# - dnQualifier: DNQ
|
||||
# - domainComponent: DC
|
||||
# - postalCode: PC
|
||||
# - streetAddress: STREET
|
||||
# - serialNumber
|
||||
# - generationQualifier
|
||||
# - pseudonym
|
||||
# - jurisdictionCountryName
|
||||
# - jurisdictionLocalityName
|
||||
# - jurisdictionStateOrProvince
|
||||
# - businessCategory
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, certificate, private_key, certificate_chain=None,
|
||||
ciphers=None,
|
||||
):
|
||||
"""Set up context in addition to base class properties if available."""
|
||||
if ssl is None:
|
||||
raise ImportError('You must install the ssl module to use HTTPS.')
|
||||
|
||||
super(BuiltinSSLAdapter, self).__init__(
|
||||
certificate, private_key, certificate_chain, ciphers,
|
||||
)
|
||||
|
||||
self.context = ssl.create_default_context(
|
||||
purpose=ssl.Purpose.CLIENT_AUTH,
|
||||
cafile=certificate_chain,
|
||||
)
|
||||
self.context.load_cert_chain(certificate, private_key)
|
||||
if self.ciphers is not None:
|
||||
self.context.set_ciphers(ciphers)
|
||||
|
||||
self._server_env = self._make_env_cert_dict(
|
||||
'SSL_SERVER',
|
||||
_parse_cert(certificate, private_key, self.certificate_chain),
|
||||
)
|
||||
if not self._server_env:
|
||||
return
|
||||
cert = None
|
||||
with open(certificate, mode='rt') as f:
|
||||
cert = f.read()
|
||||
|
||||
# strip off any keys by only taking the first certificate
|
||||
cert_start = cert.find(ssl.PEM_HEADER)
|
||||
if cert_start == -1:
|
||||
return
|
||||
cert_end = cert.find(ssl.PEM_FOOTER, cert_start)
|
||||
if cert_end == -1:
|
||||
return
|
||||
cert_end += len(ssl.PEM_FOOTER)
|
||||
self._server_env['SSL_SERVER_CERT'] = cert[cert_start:cert_end]
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
""":py:class:`~ssl.SSLContext` that will be used to wrap sockets."""
|
||||
return self._context
|
||||
|
||||
@context.setter
|
||||
def context(self, context):
|
||||
"""Set the ssl ``context`` to use."""
|
||||
self._context = context
|
||||
# Python 3.7+
|
||||
# if a context is provided via `cherrypy.config.update` then
|
||||
# `self.context` will be set after `__init__`
|
||||
# use a property to intercept it to add an SNI callback
|
||||
# but don't override the user's callback
|
||||
# TODO: chain callbacks
|
||||
with suppress(AttributeError):
|
||||
if ssl.HAS_SNI and context.sni_callback is None:
|
||||
context.sni_callback = _sni_callback
|
||||
|
||||
def bind(self, sock):
|
||||
"""Wrap and return the given socket."""
|
||||
return super(BuiltinSSLAdapter, self).bind(sock)
|
||||
|
||||
def wrap(self, sock):
|
||||
"""Wrap and return the given socket, plus WSGI environ entries."""
|
||||
EMPTY_RESULT = None, {}
|
||||
try:
|
||||
s = self.context.wrap_socket(
|
||||
sock, do_handshake_on_connect=True, server_side=True,
|
||||
)
|
||||
except ssl.SSLError as ex:
|
||||
if ex.errno == ssl.SSL_ERROR_EOF:
|
||||
# This is almost certainly due to the cherrypy engine
|
||||
# 'pinging' the socket to assert it's connectable;
|
||||
# the 'ping' isn't SSL.
|
||||
return EMPTY_RESULT
|
||||
elif ex.errno == ssl.SSL_ERROR_SSL:
|
||||
if _assert_ssl_exc_contains(ex, 'http request'):
|
||||
# The client is speaking HTTP to an HTTPS server.
|
||||
raise errors.NoSSLError
|
||||
|
||||
# Check if it's one of the known errors
|
||||
# Errors that are caught by PyOpenSSL, but thrown by
|
||||
# built-in ssl
|
||||
_block_errors = (
|
||||
'unknown protocol', 'unknown ca', 'unknown_ca',
|
||||
'unknown error',
|
||||
'https proxy request', 'inappropriate fallback',
|
||||
'wrong version number',
|
||||
'no shared cipher', 'certificate unknown',
|
||||
'ccs received early',
|
||||
'certificate verify failed', # client cert w/o trusted CA
|
||||
'version too low', # caused by SSL3 connections
|
||||
'unsupported protocol', # caused by TLS1 connections
|
||||
)
|
||||
if _assert_ssl_exc_contains(ex, *_block_errors):
|
||||
# Accepted error, let's pass
|
||||
return EMPTY_RESULT
|
||||
elif _assert_ssl_exc_contains(ex, 'handshake operation timed out'):
|
||||
# This error is thrown by builtin SSL after a timeout
|
||||
# when client is speaking HTTP to an HTTPS server.
|
||||
# The connection can safely be dropped.
|
||||
return EMPTY_RESULT
|
||||
raise
|
||||
except generic_socket_error as exc:
|
||||
"""It is unclear why exactly this happens.
|
||||
|
||||
It's reproducible only with openssl>1.0 and stdlib
|
||||
:py:mod:`ssl` wrapper.
|
||||
In CherryPy it's triggered by Checker plugin, which connects
|
||||
to the app listening to the socket port in TLS mode via plain
|
||||
HTTP during startup (from the same process).
|
||||
|
||||
|
||||
Ref: https://github.com/cherrypy/cherrypy/issues/1618
|
||||
"""
|
||||
is_error0 = exc.args == (0, 'Error')
|
||||
|
||||
if is_error0 and IS_ABOVE_OPENSSL10:
|
||||
return EMPTY_RESULT
|
||||
raise
|
||||
return s, self.get_environ(s)
|
||||
|
||||
def get_environ(self, sock):
|
||||
"""Create WSGI environ entries to be merged into each request."""
|
||||
cipher = sock.cipher()
|
||||
ssl_environ = {
|
||||
'wsgi.url_scheme': 'https',
|
||||
'HTTPS': 'on',
|
||||
'SSL_PROTOCOL': cipher[1],
|
||||
'SSL_CIPHER': cipher[0],
|
||||
'SSL_CIPHER_EXPORT': '',
|
||||
'SSL_CIPHER_USEKEYSIZE': cipher[2],
|
||||
'SSL_VERSION_INTERFACE': '%s Python/%s' % (
|
||||
HTTPServer.version, sys.version,
|
||||
),
|
||||
'SSL_VERSION_LIBRARY': ssl.OPENSSL_VERSION,
|
||||
'SSL_CLIENT_VERIFY': 'NONE',
|
||||
# 'NONE' - client did not provide a cert (overriden below)
|
||||
}
|
||||
|
||||
# Python 3.3+
|
||||
with suppress(AttributeError):
|
||||
compression = sock.compression()
|
||||
if compression is not None:
|
||||
ssl_environ['SSL_COMPRESS_METHOD'] = compression
|
||||
|
||||
# Python 3.6+
|
||||
with suppress(AttributeError):
|
||||
ssl_environ['SSL_SESSION_ID'] = sock.session.id.hex()
|
||||
with suppress(AttributeError):
|
||||
target_cipher = cipher[:2]
|
||||
for cip in sock.context.get_ciphers():
|
||||
if target_cipher == (cip['name'], cip['protocol']):
|
||||
ssl_environ['SSL_CIPHER_ALGKEYSIZE'] = cip['alg_bits']
|
||||
break
|
||||
|
||||
# Python 3.7+ sni_callback
|
||||
with suppress(AttributeError):
|
||||
ssl_environ['SSL_TLS_SNI'] = sock.sni
|
||||
|
||||
if self.context and self.context.verify_mode != ssl.CERT_NONE:
|
||||
client_cert = sock.getpeercert()
|
||||
if client_cert:
|
||||
# builtin ssl **ALWAYS** validates client certificates
|
||||
# and terminates the connection on failure
|
||||
ssl_environ['SSL_CLIENT_VERIFY'] = 'SUCCESS'
|
||||
ssl_environ.update(
|
||||
self._make_env_cert_dict('SSL_CLIENT', client_cert),
|
||||
)
|
||||
ssl_environ['SSL_CLIENT_CERT'] = ssl.DER_cert_to_PEM_cert(
|
||||
sock.getpeercert(binary_form=True),
|
||||
).strip()
|
||||
|
||||
ssl_environ.update(self._server_env)
|
||||
|
||||
# not supplied by the Python standard library (as of 3.8)
|
||||
# - SSL_SESSION_RESUMED
|
||||
# - SSL_SECURE_RENEG
|
||||
# - SSL_CLIENT_CERT_CHAIN_n
|
||||
# - SRP_USER
|
||||
# - SRP_USERINFO
|
||||
|
||||
return ssl_environ
|
||||
|
||||
def _make_env_cert_dict(self, env_prefix, parsed_cert):
|
||||
"""Return a dict of WSGI environment variables for a certificate.
|
||||
|
||||
E.g. SSL_CLIENT_M_VERSION, SSL_CLIENT_M_SERIAL, etc.
|
||||
See https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#envvars.
|
||||
"""
|
||||
if not parsed_cert:
|
||||
return {}
|
||||
|
||||
env = {}
|
||||
for cert_key, env_var in self.CERT_KEY_TO_ENV.items():
|
||||
key = '%s_%s' % (env_prefix, env_var)
|
||||
value = parsed_cert.get(cert_key)
|
||||
if env_var == 'SAN':
|
||||
env.update(self._make_env_san_dict(key, value))
|
||||
elif env_var.endswith('_DN'):
|
||||
env.update(self._make_env_dn_dict(key, value))
|
||||
else:
|
||||
env[key] = str(value)
|
||||
|
||||
# mod_ssl 2.1+; Python 3.2+
|
||||
# number of days until the certificate expires
|
||||
if 'notBefore' in parsed_cert:
|
||||
remain = ssl.cert_time_to_seconds(parsed_cert['notAfter'])
|
||||
remain -= ssl.cert_time_to_seconds(parsed_cert['notBefore'])
|
||||
remain /= 60 * 60 * 24
|
||||
env['%s_V_REMAIN' % (env_prefix,)] = str(int(remain))
|
||||
|
||||
return env
|
||||
|
||||
def _make_env_san_dict(self, env_prefix, cert_value):
|
||||
"""Return a dict of WSGI environment variables for a certificate DN.
|
||||
|
||||
E.g. SSL_CLIENT_SAN_Email_0, SSL_CLIENT_SAN_DNS_0, etc.
|
||||
See SSL_CLIENT_SAN_* at
|
||||
https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#envvars.
|
||||
"""
|
||||
if not cert_value:
|
||||
return {}
|
||||
|
||||
env = {}
|
||||
dns_count = 0
|
||||
email_count = 0
|
||||
for attr_name, val in cert_value:
|
||||
if attr_name == 'DNS':
|
||||
env['%s_DNS_%i' % (env_prefix, dns_count)] = val
|
||||
dns_count += 1
|
||||
elif attr_name == 'Email':
|
||||
env['%s_Email_%i' % (env_prefix, email_count)] = val
|
||||
email_count += 1
|
||||
|
||||
# other mod_ssl SAN vars:
|
||||
# - SAN_OTHER_msUPN_n
|
||||
return env
|
||||
|
||||
def _make_env_dn_dict(self, env_prefix, cert_value):
|
||||
"""Return a dict of WSGI environment variables for a certificate DN.
|
||||
|
||||
E.g. SSL_CLIENT_S_DN_CN, SSL_CLIENT_S_DN_C, etc.
|
||||
See SSL_CLIENT_S_DN_x509 at
|
||||
https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#envvars.
|
||||
"""
|
||||
if not cert_value:
|
||||
return {}
|
||||
|
||||
dn = []
|
||||
dn_attrs = {}
|
||||
for rdn in cert_value:
|
||||
for attr_name, val in rdn:
|
||||
attr_code = self.CERT_KEY_TO_LDAP_CODE.get(attr_name)
|
||||
dn.append('%s=%s' % (attr_code or attr_name, val))
|
||||
if not attr_code:
|
||||
continue
|
||||
dn_attrs.setdefault(attr_code, [])
|
||||
dn_attrs[attr_code].append(val)
|
||||
|
||||
env = {
|
||||
env_prefix: ','.join(dn),
|
||||
}
|
||||
for attr_code, values in dn_attrs.items():
|
||||
env['%s_%s' % (env_prefix, attr_code)] = ','.join(values)
|
||||
if len(values) == 1:
|
||||
continue
|
||||
for i, val in enumerate(values):
|
||||
env['%s_%s_%i' % (env_prefix, attr_code, i)] = val
|
||||
return env
|
||||
|
||||
def makefile(self, sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE):
|
||||
"""Return socket file object."""
|
||||
cls = StreamReader if 'r' in mode else StreamWriter
|
||||
return cls(sock, mode, bufsize)
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import Any
|
||||
from . import Adapter
|
||||
|
||||
generic_socket_error: OSError
|
||||
DEFAULT_BUFFER_SIZE: int
|
||||
|
||||
class BuiltinSSLAdapter(Adapter):
|
||||
CERT_KEY_TO_ENV: Any
|
||||
CERT_KEY_TO_LDAP_CODE: Any
|
||||
def __init__(self, certificate, private_key, certificate_chain: Any | None = ..., ciphers: Any | None = ...) -> None: ...
|
||||
@property
|
||||
def context(self): ...
|
||||
@context.setter
|
||||
def context(self, context) -> None: ...
|
||||
def bind(self, sock): ...
|
||||
def wrap(self, sock): ...
|
||||
def get_environ(self): ...
|
||||
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
|
||||
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
A library for integrating :doc:`pyOpenSSL <pyopenssl:index>` with Cheroot.
|
||||
|
||||
The :py:mod:`OpenSSL <pyopenssl:OpenSSL>` module must be importable
|
||||
for SSL/TLS/HTTPS functionality.
|
||||
You can obtain it from `here <https://github.com/pyca/pyopenssl>`_.
|
||||
|
||||
To use this module, set :py:attr:`HTTPServer.ssl_adapter
|
||||
<cheroot.server.HTTPServer.ssl_adapter>` to an instance of
|
||||
:py:class:`ssl.Adapter <cheroot.ssl.Adapter>`.
|
||||
There are two ways to use :abbr:`TLS (Transport-Level Security)`:
|
||||
|
||||
Method One
|
||||
----------
|
||||
|
||||
* :py:attr:`ssl_adapter.context
|
||||
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.context>`: an instance of
|
||||
:py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`.
|
||||
|
||||
If this is not None, it is assumed to be an :py:class:`SSL.Context
|
||||
<pyopenssl:OpenSSL.SSL.Context>` instance, and will be passed to
|
||||
:py:class:`SSL.Connection <pyopenssl:OpenSSL.SSL.Connection>` on bind().
|
||||
The developer is responsible for forming a valid :py:class:`Context
|
||||
<pyopenssl:OpenSSL.SSL.Context>` object. This
|
||||
approach is to be preferred for more flexibility, e.g. if the cert and
|
||||
key are streams instead of files, or need decryption, or
|
||||
:py:data:`SSL.SSLv3_METHOD <pyopenssl:OpenSSL.SSL.SSLv3_METHOD>`
|
||||
is desired instead of the default :py:data:`SSL.SSLv23_METHOD
|
||||
<pyopenssl:OpenSSL.SSL.SSLv3_METHOD>`, etc. Consult
|
||||
the :doc:`pyOpenSSL <pyopenssl:api/ssl>` documentation for
|
||||
complete options.
|
||||
|
||||
Method Two (shortcut)
|
||||
---------------------
|
||||
|
||||
* :py:attr:`ssl_adapter.certificate
|
||||
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.certificate>`: the file name
|
||||
of the server's TLS certificate.
|
||||
* :py:attr:`ssl_adapter.private_key
|
||||
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.private_key>`: the file name
|
||||
of the server's private key file.
|
||||
|
||||
Both are :py:data:`None` by default. If :py:attr:`ssl_adapter.context
|
||||
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.context>` is :py:data:`None`,
|
||||
but ``.private_key`` and ``.certificate`` are both given and valid, they
|
||||
will be read, and the context will be automatically created from them.
|
||||
|
||||
.. spelling::
|
||||
|
||||
pyopenssl
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import six
|
||||
|
||||
try:
|
||||
import OpenSSL.version
|
||||
from OpenSSL import SSL
|
||||
from OpenSSL import crypto
|
||||
|
||||
try:
|
||||
ssl_conn_type = SSL.Connection
|
||||
except AttributeError:
|
||||
ssl_conn_type = SSL.ConnectionType
|
||||
except ImportError:
|
||||
SSL = None
|
||||
|
||||
from . import Adapter
|
||||
from .. import errors, server as cheroot_server
|
||||
from ..makefile import StreamReader, StreamWriter
|
||||
|
||||
|
||||
class SSLFileobjectMixin:
|
||||
"""Base mixin for a TLS socket stream."""
|
||||
|
||||
ssl_timeout = 3
|
||||
ssl_retry = .01
|
||||
|
||||
# FIXME:
|
||||
def _safe_call(self, is_reader, call, *args, **kwargs): # noqa: C901
|
||||
"""Wrap the given call with TLS error-trapping.
|
||||
|
||||
is_reader: if False EOF errors will be raised. If True, EOF errors
|
||||
will return "" (to emulate normal sockets).
|
||||
"""
|
||||
start = time.time()
|
||||
while True:
|
||||
try:
|
||||
return call(*args, **kwargs)
|
||||
except SSL.WantReadError:
|
||||
# Sleep and try again. This is dangerous, because it means
|
||||
# the rest of the stack has no way of differentiating
|
||||
# between a "new handshake" error and "client dropped".
|
||||
# Note this isn't an endless loop: there's a timeout below.
|
||||
# Ref: https://stackoverflow.com/a/5133568/595220
|
||||
time.sleep(self.ssl_retry)
|
||||
except SSL.WantWriteError:
|
||||
time.sleep(self.ssl_retry)
|
||||
except SSL.SysCallError as e:
|
||||
if is_reader and e.args == (-1, 'Unexpected EOF'):
|
||||
return b''
|
||||
|
||||
errnum = e.args[0]
|
||||
if is_reader and errnum in errors.socket_errors_to_ignore:
|
||||
return b''
|
||||
raise socket.error(errnum)
|
||||
except SSL.Error as e:
|
||||
if is_reader and e.args == (-1, 'Unexpected EOF'):
|
||||
return b''
|
||||
|
||||
thirdarg = None
|
||||
try:
|
||||
thirdarg = e.args[0][0][2]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
if thirdarg == 'http request':
|
||||
# The client is talking HTTP to an HTTPS server.
|
||||
raise errors.NoSSLError()
|
||||
|
||||
raise errors.FatalSSLAlert(*e.args)
|
||||
|
||||
if time.time() - start > self.ssl_timeout:
|
||||
raise socket.timeout('timed out')
|
||||
|
||||
def recv(self, size):
|
||||
"""Receive message of a size from the socket."""
|
||||
return self._safe_call(
|
||||
True,
|
||||
super(SSLFileobjectMixin, self).recv,
|
||||
size,
|
||||
)
|
||||
|
||||
def readline(self, size=-1):
|
||||
"""Receive message of a size from the socket.
|
||||
|
||||
Matches the following interface:
|
||||
https://docs.python.org/3/library/io.html#io.IOBase.readline
|
||||
"""
|
||||
return self._safe_call(
|
||||
True,
|
||||
super(SSLFileobjectMixin, self).readline,
|
||||
size,
|
||||
)
|
||||
|
||||
def sendall(self, *args, **kwargs):
|
||||
"""Send whole message to the socket."""
|
||||
return self._safe_call(
|
||||
False,
|
||||
super(SSLFileobjectMixin, self).sendall,
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
def send(self, *args, **kwargs):
|
||||
"""Send some part of message to the socket."""
|
||||
return self._safe_call(
|
||||
False,
|
||||
super(SSLFileobjectMixin, self).send,
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class SSLFileobjectStreamReader(SSLFileobjectMixin, StreamReader):
|
||||
"""SSL file object attached to a socket object."""
|
||||
|
||||
|
||||
class SSLFileobjectStreamWriter(SSLFileobjectMixin, StreamWriter):
|
||||
"""SSL file object attached to a socket object."""
|
||||
|
||||
|
||||
class SSLConnectionProxyMeta:
|
||||
"""Metaclass for generating a bunch of proxy methods."""
|
||||
|
||||
def __new__(mcl, name, bases, nmspc):
|
||||
"""Attach a list of proxy methods to a new class."""
|
||||
proxy_methods = (
|
||||
'get_context', 'pending', 'send', 'write', 'recv', 'read',
|
||||
'renegotiate', 'bind', 'listen', 'connect', 'accept',
|
||||
'setblocking', 'fileno', 'close', 'get_cipher_list',
|
||||
'getpeername', 'getsockname', 'getsockopt', 'setsockopt',
|
||||
'makefile', 'get_app_data', 'set_app_data', 'state_string',
|
||||
'sock_shutdown', 'get_peer_certificate', 'want_read',
|
||||
'want_write', 'set_connect_state', 'set_accept_state',
|
||||
'connect_ex', 'sendall', 'settimeout', 'gettimeout',
|
||||
'shutdown',
|
||||
)
|
||||
proxy_methods_no_args = (
|
||||
'shutdown',
|
||||
)
|
||||
|
||||
proxy_props = (
|
||||
'family',
|
||||
)
|
||||
|
||||
def lock_decorator(method):
|
||||
"""Create a proxy method for a new class."""
|
||||
def proxy_wrapper(self, *args):
|
||||
self._lock.acquire()
|
||||
try:
|
||||
new_args = (
|
||||
args[:] if method not in proxy_methods_no_args else []
|
||||
)
|
||||
return getattr(self._ssl_conn, method)(*new_args)
|
||||
finally:
|
||||
self._lock.release()
|
||||
return proxy_wrapper
|
||||
for m in proxy_methods:
|
||||
nmspc[m] = lock_decorator(m)
|
||||
nmspc[m].__name__ = m
|
||||
|
||||
def make_property(property_):
|
||||
"""Create a proxy method for a new class."""
|
||||
def proxy_prop_wrapper(self):
|
||||
return getattr(self._ssl_conn, property_)
|
||||
proxy_prop_wrapper.__name__ = property_
|
||||
return property(proxy_prop_wrapper)
|
||||
for p in proxy_props:
|
||||
nmspc[p] = make_property(p)
|
||||
|
||||
# Doesn't work via super() for some reason.
|
||||
# Falling back to type() instead:
|
||||
return type(name, bases, nmspc)
|
||||
|
||||
|
||||
@six.add_metaclass(SSLConnectionProxyMeta)
|
||||
class SSLConnection:
|
||||
r"""A thread-safe wrapper for an ``SSL.Connection``.
|
||||
|
||||
:param tuple args: the arguments to create the wrapped \
|
||||
:py:class:`SSL.Connection(*args) \
|
||||
<pyopenssl:OpenSSL.SSL.Connection>`
|
||||
"""
|
||||
|
||||
def __init__(self, *args):
|
||||
"""Initialize SSLConnection instance."""
|
||||
self._ssl_conn = SSL.Connection(*args)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
|
||||
class pyOpenSSLAdapter(Adapter):
|
||||
"""A wrapper for integrating pyOpenSSL with Cheroot."""
|
||||
|
||||
certificate = None
|
||||
"""The file name of the server's TLS certificate."""
|
||||
|
||||
private_key = None
|
||||
"""The file name of the server's private key file."""
|
||||
|
||||
certificate_chain = None
|
||||
"""Optional. The file name of CA's intermediate certificate bundle.
|
||||
|
||||
This is needed for cheaper "chained root" TLS certificates,
|
||||
and should be left as :py:data:`None` if not required."""
|
||||
|
||||
context = None
|
||||
"""
|
||||
An instance of :py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`.
|
||||
"""
|
||||
|
||||
ciphers = None
|
||||
"""The ciphers list of TLS."""
|
||||
|
||||
def __init__(
|
||||
self, certificate, private_key, certificate_chain=None,
|
||||
ciphers=None,
|
||||
):
|
||||
"""Initialize OpenSSL Adapter instance."""
|
||||
if SSL is None:
|
||||
raise ImportError('You must install pyOpenSSL to use HTTPS.')
|
||||
|
||||
super(pyOpenSSLAdapter, self).__init__(
|
||||
certificate, private_key, certificate_chain, ciphers,
|
||||
)
|
||||
|
||||
self._environ = None
|
||||
|
||||
def bind(self, sock):
|
||||
"""Wrap and return the given socket."""
|
||||
if self.context is None:
|
||||
self.context = self.get_context()
|
||||
conn = SSLConnection(self.context, sock)
|
||||
self._environ = self.get_environ()
|
||||
return conn
|
||||
|
||||
def wrap(self, sock):
|
||||
"""Wrap and return the given socket, plus WSGI environ entries."""
|
||||
# pyOpenSSL doesn't perform the handshake until the first read/write
|
||||
# forcing the handshake to complete tends to result in the connection
|
||||
# closing so we can't reliably access protocol/client cert for the env
|
||||
return sock, self._environ.copy()
|
||||
|
||||
def get_context(self):
|
||||
"""Return an ``SSL.Context`` from self attributes.
|
||||
|
||||
Ref: :py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`
|
||||
"""
|
||||
# See https://code.activestate.com/recipes/442473/
|
||||
c = SSL.Context(SSL.SSLv23_METHOD)
|
||||
c.use_privatekey_file(self.private_key)
|
||||
if self.certificate_chain:
|
||||
c.load_verify_locations(self.certificate_chain)
|
||||
c.use_certificate_file(self.certificate)
|
||||
return c
|
||||
|
||||
def get_environ(self):
|
||||
"""Return WSGI environ entries to be merged into each request."""
|
||||
ssl_environ = {
|
||||
'wsgi.url_scheme': 'https',
|
||||
'HTTPS': 'on',
|
||||
'SSL_VERSION_INTERFACE': '%s %s/%s Python/%s' % (
|
||||
cheroot_server.HTTPServer.version,
|
||||
OpenSSL.version.__title__, OpenSSL.version.__version__,
|
||||
sys.version,
|
||||
),
|
||||
'SSL_VERSION_LIBRARY': SSL.SSLeay_version(
|
||||
SSL.SSLEAY_VERSION,
|
||||
).decode(),
|
||||
}
|
||||
|
||||
if self.certificate:
|
||||
# Server certificate attributes
|
||||
with open(self.certificate, 'rb') as cert_file:
|
||||
cert = crypto.load_certificate(
|
||||
crypto.FILETYPE_PEM, cert_file.read(),
|
||||
)
|
||||
|
||||
ssl_environ.update({
|
||||
'SSL_SERVER_M_VERSION': cert.get_version(),
|
||||
'SSL_SERVER_M_SERIAL': cert.get_serial_number(),
|
||||
# 'SSL_SERVER_V_START':
|
||||
# Validity of server's certificate (start time),
|
||||
# 'SSL_SERVER_V_END':
|
||||
# Validity of server's certificate (end time),
|
||||
})
|
||||
|
||||
for prefix, dn in [
|
||||
('I', cert.get_issuer()),
|
||||
('S', cert.get_subject()),
|
||||
]:
|
||||
# X509Name objects don't seem to have a way to get the
|
||||
# complete DN string. Use str() and slice it instead,
|
||||
# because str(dn) == "<X509Name object '/C=US/ST=...'>"
|
||||
dnstr = str(dn)[18:-2]
|
||||
|
||||
wsgikey = 'SSL_SERVER_%s_DN' % prefix
|
||||
ssl_environ[wsgikey] = dnstr
|
||||
|
||||
# The DN should be of the form: /k1=v1/k2=v2, but we must allow
|
||||
# for any value to contain slashes itself (in a URL).
|
||||
while dnstr:
|
||||
pos = dnstr.rfind('=')
|
||||
dnstr, value = dnstr[:pos], dnstr[pos + 1:]
|
||||
pos = dnstr.rfind('/')
|
||||
dnstr, key = dnstr[:pos], dnstr[pos + 1:]
|
||||
if key and value:
|
||||
wsgikey = 'SSL_SERVER_%s_DN_%s' % (prefix, key)
|
||||
ssl_environ[wsgikey] = value
|
||||
|
||||
return ssl_environ
|
||||
|
||||
def makefile(self, sock, mode='r', bufsize=-1):
|
||||
"""Return socket file object."""
|
||||
cls = (
|
||||
SSLFileobjectStreamReader
|
||||
if 'r' in mode else
|
||||
SSLFileobjectStreamWriter
|
||||
)
|
||||
if SSL and isinstance(sock, ssl_conn_type):
|
||||
wrapped_socket = cls(sock, mode, bufsize)
|
||||
wrapped_socket.ssl_timeout = sock.gettimeout()
|
||||
return wrapped_socket
|
||||
# This is from past:
|
||||
# TODO: figure out what it's meant for
|
||||
else:
|
||||
return cheroot_server.CP_fileobject(sock, mode, bufsize)
|
||||
@@ -0,0 +1,30 @@
|
||||
from . import Adapter
|
||||
from ..makefile import StreamReader, StreamWriter
|
||||
from OpenSSL import SSL
|
||||
from typing import Any
|
||||
|
||||
ssl_conn_type: SSL.Connection
|
||||
|
||||
class SSLFileobjectMixin:
|
||||
ssl_timeout: int
|
||||
ssl_retry: float
|
||||
def recv(self, size): ...
|
||||
def readline(self, size: int = ...): ...
|
||||
def sendall(self, *args, **kwargs): ...
|
||||
def send(self, *args, **kwargs): ...
|
||||
|
||||
class SSLFileobjectStreamReader(SSLFileobjectMixin, StreamReader): ... # type:ignore
|
||||
class SSLFileobjectStreamWriter(SSLFileobjectMixin, StreamWriter): ... # type:ignore
|
||||
|
||||
class SSLConnectionProxyMeta:
|
||||
def __new__(mcl, name, bases, nmspc): ...
|
||||
|
||||
class SSLConnection():
|
||||
def __init__(self, *args) -> None: ...
|
||||
|
||||
class pyOpenSSLAdapter(Adapter):
|
||||
def __init__(self, certificate, private_key, certificate_chain: Any | None = ..., ciphers: Any | None = ...) -> None: ...
|
||||
def bind(self, sock): ...
|
||||
def wrap(self, sock): ...
|
||||
def get_environ(self): ...
|
||||
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
|
||||
@@ -0,0 +1 @@
|
||||
"""Cheroot test suite."""
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Local pytest plugin.
|
||||
|
||||
Contains hooks, which are tightly bound to the Cheroot framework
|
||||
itself, useless for end-users' app testing.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytest_version = tuple(map(int, pytest.__version__.split('.')))
|
||||
|
||||
|
||||
def pytest_load_initial_conftests(early_config, parser, args):
|
||||
"""Drop unfilterable warning ignores."""
|
||||
if pytest_version < (6, 2, 0):
|
||||
return
|
||||
|
||||
# pytest>=6.2.0 under Python 3.8:
|
||||
# Refs:
|
||||
# * https://docs.pytest.org/en/stable/usage.html#unraisable
|
||||
# * https://github.com/pytest-dev/pytest/issues/5299
|
||||
early_config._inicache['filterwarnings'].extend((
|
||||
'ignore:Exception in thread CP Server Thread-:'
|
||||
'pytest.PytestUnhandledThreadExceptionWarning:_pytest.threadexception',
|
||||
'ignore:Exception in thread Thread-:'
|
||||
'pytest.PytestUnhandledThreadExceptionWarning:_pytest.threadexception',
|
||||
'ignore:Exception ignored in. '
|
||||
'<socket.socket fd=-1, family=AddressFamily.AF_INET, '
|
||||
'type=SocketKind.SOCK_STREAM, proto=.:'
|
||||
'pytest.PytestUnraisableExceptionWarning:_pytest.unraisableexception',
|
||||
'ignore:Exception ignored in. '
|
||||
'<socket.socket fd=-1, family=AddressFamily.AF_INET6, '
|
||||
'type=SocketKind.SOCK_STREAM, proto=.:'
|
||||
'pytest.PytestUnraisableExceptionWarning:_pytest.unraisableexception',
|
||||
'ignore:Exception ignored in. '
|
||||
'<socket.socket fd=-1, family=AF_INET, '
|
||||
'type=SocketKind.SOCK_STREAM, proto=.:'
|
||||
'pytest.PytestUnraisableExceptionWarning:_pytest.unraisableexception',
|
||||
'ignore:Exception ignored in. '
|
||||
'<socket.socket fd=-1, family=AF_INET6, '
|
||||
'type=SocketKind.SOCK_STREAM, proto=.:'
|
||||
'pytest.PytestUnraisableExceptionWarning:_pytest.unraisableexception',
|
||||
'ignore:Exception ignored in. '
|
||||
'<ssl.SSLSocket fd=-1, family=AddressFamily.AF_UNIX, '
|
||||
'type=SocketKind.SOCK_STREAM, proto=.:'
|
||||
'pytest.PytestUnraisableExceptionWarning:_pytest.unraisableexception',
|
||||
))
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Pytest configuration module.
|
||||
|
||||
Contains fixtures, which are tightly bound to the Cheroot framework
|
||||
itself, useless for end-users' app testing.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type # pylint: disable=invalid-name
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ..server import Gateway, HTTPServer
|
||||
from ..testing import ( # noqa: F401 # pylint: disable=unused-import
|
||||
native_server, wsgi_server,
|
||||
)
|
||||
from ..testing import get_server_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
# pylint: disable=redefined-outer-name
|
||||
def wsgi_server_client(wsgi_server): # noqa: F811
|
||||
"""Create a test client out of given WSGI server."""
|
||||
return get_server_client(wsgi_server)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
# pylint: disable=redefined-outer-name
|
||||
def native_server_client(native_server): # noqa: F811
|
||||
"""Create a test client out of given HTTP server."""
|
||||
return get_server_client(native_server)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_server():
|
||||
"""Provision a server creator as a fixture."""
|
||||
def start_srv():
|
||||
bind_addr = yield
|
||||
if bind_addr is None:
|
||||
return
|
||||
httpserver = make_http_server(bind_addr)
|
||||
yield httpserver
|
||||
yield httpserver
|
||||
|
||||
srv_creator = iter(start_srv())
|
||||
next(srv_creator) # pylint: disable=stop-iteration-return
|
||||
yield srv_creator
|
||||
try:
|
||||
while True:
|
||||
httpserver = next(srv_creator)
|
||||
if httpserver is not None:
|
||||
httpserver.stop()
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
|
||||
def make_http_server(bind_addr):
|
||||
"""Create and start an HTTP server bound to ``bind_addr``."""
|
||||
httpserver = HTTPServer(
|
||||
bind_addr=bind_addr,
|
||||
gateway=Gateway,
|
||||
)
|
||||
|
||||
threading.Thread(target=httpserver.safe_start).start()
|
||||
|
||||
while not httpserver.ready:
|
||||
time.sleep(0.1)
|
||||
|
||||
return httpserver
|
||||
@@ -0,0 +1,174 @@
|
||||
"""A library of helper functions for the Cheroot test suite."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
|
||||
from six.moves import http_client
|
||||
|
||||
import six
|
||||
|
||||
import cheroot.server
|
||||
import cheroot.wsgi
|
||||
|
||||
from cheroot.test import webtest
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
thisdir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
config = {
|
||||
'bind_addr': ('127.0.0.1', 54583),
|
||||
'server': 'wsgi',
|
||||
'wsgi_app': None,
|
||||
}
|
||||
|
||||
|
||||
class CherootWebCase(webtest.WebCase):
|
||||
"""Helper class for a web app test suite."""
|
||||
|
||||
script_name = ''
|
||||
scheme = 'http'
|
||||
|
||||
available_servers = {
|
||||
'wsgi': cheroot.wsgi.Server,
|
||||
'native': cheroot.server.HTTPServer,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
"""Create and run one HTTP server per class."""
|
||||
conf = config.copy()
|
||||
conf.update(getattr(cls, 'config', {}))
|
||||
|
||||
s_class = conf.pop('server', 'wsgi')
|
||||
server_factory = cls.available_servers.get(s_class)
|
||||
if server_factory is None:
|
||||
raise RuntimeError('Unknown server in config: %s' % conf['server'])
|
||||
cls.httpserver = server_factory(**conf)
|
||||
|
||||
cls.HOST, cls.PORT = cls.httpserver.bind_addr
|
||||
if cls.httpserver.ssl_adapter is None:
|
||||
ssl = ''
|
||||
cls.scheme = 'http'
|
||||
else:
|
||||
ssl = ' (ssl)'
|
||||
cls.HTTP_CONN = http_client.HTTPSConnection
|
||||
cls.scheme = 'https'
|
||||
|
||||
v = sys.version.split()[0]
|
||||
log.info('Python version used to run this test script: %s' % v)
|
||||
log.info('Cheroot version: %s' % cheroot.__version__)
|
||||
log.info('HTTP server version: %s%s' % (cls.httpserver.protocol, ssl))
|
||||
log.info('PID: %s' % os.getpid())
|
||||
|
||||
if hasattr(cls, 'setup_server'):
|
||||
# Clear the wsgi server so that
|
||||
# it can be updated with the new root
|
||||
cls.setup_server()
|
||||
cls.start()
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
"""Cleanup HTTP server."""
|
||||
if hasattr(cls, 'setup_server'):
|
||||
cls.stop()
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
"""Load and start the HTTP server."""
|
||||
threading.Thread(target=cls.httpserver.safe_start).start()
|
||||
while not cls.httpserver.ready:
|
||||
time.sleep(0.1)
|
||||
|
||||
@classmethod
|
||||
def stop(cls):
|
||||
"""Terminate HTTP server."""
|
||||
cls.httpserver.stop()
|
||||
td = getattr(cls, 'teardown', None)
|
||||
if td:
|
||||
td()
|
||||
|
||||
date_tolerance = 2
|
||||
|
||||
def assertEqualDates(self, dt1, dt2, seconds=None):
|
||||
"""Assert ``abs(dt1 - dt2)`` is within ``Y`` seconds."""
|
||||
if seconds is None:
|
||||
seconds = self.date_tolerance
|
||||
|
||||
if dt1 > dt2:
|
||||
diff = dt1 - dt2
|
||||
else:
|
||||
diff = dt2 - dt1
|
||||
if not diff < datetime.timedelta(seconds=seconds):
|
||||
raise AssertionError(
|
||||
'%r and %r are not within %r seconds.' %
|
||||
(dt1, dt2, seconds),
|
||||
)
|
||||
|
||||
|
||||
class Request:
|
||||
"""HTTP request container."""
|
||||
|
||||
def __init__(self, environ):
|
||||
"""Initialize HTTP request."""
|
||||
self.environ = environ
|
||||
|
||||
|
||||
class Response:
|
||||
"""HTTP response container."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize HTTP response."""
|
||||
self.status = '200 OK'
|
||||
self.headers = {'Content-Type': 'text/html'}
|
||||
self.body = None
|
||||
|
||||
def output(self):
|
||||
"""Generate iterable response body object."""
|
||||
if self.body is None:
|
||||
return []
|
||||
elif isinstance(self.body, six.text_type):
|
||||
return [self.body.encode('iso-8859-1')]
|
||||
elif isinstance(self.body, six.binary_type):
|
||||
return [self.body]
|
||||
else:
|
||||
return [x.encode('iso-8859-1') for x in self.body]
|
||||
|
||||
|
||||
class Controller:
|
||||
"""WSGI app for tests."""
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
"""WSGI request handler."""
|
||||
req, resp = Request(environ), Response()
|
||||
try:
|
||||
# Python 3 supports unicode attribute names
|
||||
# Python 2 encodes them
|
||||
handler = self.handlers[environ['PATH_INFO']]
|
||||
except KeyError:
|
||||
resp.status = '404 Not Found'
|
||||
else:
|
||||
output = handler(req, resp)
|
||||
if (
|
||||
output is not None
|
||||
and not any(
|
||||
resp.status.startswith(status_code)
|
||||
for status_code in ('204', '304')
|
||||
)
|
||||
):
|
||||
resp.body = output
|
||||
try:
|
||||
resp.headers.setdefault('Content-Length', str(len(output)))
|
||||
except TypeError:
|
||||
if not isinstance(output, types.GeneratorType):
|
||||
raise
|
||||
start_response(resp.status, resp.headers.items())
|
||||
return resp.output()
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test suite for cross-python compatibility helpers."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
import six
|
||||
|
||||
from cheroot._compat import extract_bytes, memoryview, ntob, ntou, bton
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('func', 'inp', 'out'),
|
||||
(
|
||||
(ntob, 'bar', b'bar'),
|
||||
(ntou, 'bar', u'bar'),
|
||||
(bton, b'bar', 'bar'),
|
||||
),
|
||||
)
|
||||
def test_compat_functions_positive(func, inp, out):
|
||||
"""Check that compatibility functions work with correct input."""
|
||||
assert func(inp, encoding='utf-8') == out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'func',
|
||||
(
|
||||
ntob,
|
||||
ntou,
|
||||
),
|
||||
)
|
||||
def test_compat_functions_negative_nonnative(func):
|
||||
"""Check that compatibility functions fail loudly for incorrect input."""
|
||||
non_native_test_str = u'bar' if six.PY2 else b'bar'
|
||||
with pytest.raises(TypeError):
|
||||
func(non_native_test_str, encoding='utf-8')
|
||||
|
||||
|
||||
def test_ntou_escape():
|
||||
"""Check that ``ntou`` supports escape-encoding under Python 2."""
|
||||
expected = u'hišřії'
|
||||
actual = ntou('hi\u0161\u0159\u0456\u0457', encoding='escape')
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('input_argument', 'expected_result'),
|
||||
(
|
||||
(b'qwerty', b'qwerty'),
|
||||
(memoryview(b'asdfgh'), b'asdfgh'),
|
||||
),
|
||||
)
|
||||
def test_extract_bytes(input_argument, expected_result):
|
||||
"""Check that legitimate inputs produce bytes."""
|
||||
assert extract_bytes(input_argument) == expected_result
|
||||
|
||||
|
||||
def test_extract_bytes_invalid():
|
||||
"""Ensure that invalid input causes exception to be raised."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'^extract_bytes\(\) only accepts bytes '
|
||||
'and memoryview/buffer$',
|
||||
):
|
||||
extract_bytes(u'some юнікод їїї')
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests to verify the command line interface.
|
||||
|
||||
.. spelling::
|
||||
|
||||
cli
|
||||
"""
|
||||
# -*- coding: utf-8 -*-
|
||||
# vim: set fileencoding=utf-8 :
|
||||
import sys
|
||||
|
||||
import six
|
||||
import pytest
|
||||
|
||||
from cheroot.cli import (
|
||||
Application,
|
||||
parse_wsgi_bind_addr,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('raw_bind_addr', 'expected_bind_addr'),
|
||||
(
|
||||
# tcp/ip
|
||||
('192.168.1.1:80', ('192.168.1.1', 80)),
|
||||
# ipv6 ips has to be enclosed in brakets when specified in url form
|
||||
('[::1]:8000', ('::1', 8000)),
|
||||
('localhost:5000', ('localhost', 5000)),
|
||||
# this is a valid input, but foo gets discarted
|
||||
('foo@bar:5000', ('bar', 5000)),
|
||||
('foo', ('foo', None)),
|
||||
('123456789', ('123456789', None)),
|
||||
# unix sockets
|
||||
('/tmp/cheroot.sock', '/tmp/cheroot.sock'),
|
||||
('/tmp/some-random-file-name', '/tmp/some-random-file-name'),
|
||||
# abstract sockets
|
||||
('@cheroot', '\x00cheroot'),
|
||||
),
|
||||
)
|
||||
def test_parse_wsgi_bind_addr(raw_bind_addr, expected_bind_addr):
|
||||
"""Check the parsing of the --bind option.
|
||||
|
||||
Verify some of the supported addresses and the expected return value.
|
||||
"""
|
||||
assert parse_wsgi_bind_addr(raw_bind_addr) == expected_bind_addr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wsgi_app(monkeypatch):
|
||||
"""Return a WSGI app stub."""
|
||||
class WSGIAppMock:
|
||||
"""Mock of a wsgi module."""
|
||||
|
||||
def application(self):
|
||||
"""Empty application method.
|
||||
|
||||
Default method to be called when no specific callable
|
||||
is defined in the wsgi application identifier.
|
||||
|
||||
It has an empty body because we are expecting to verify that
|
||||
the same method is return no the actual execution of it.
|
||||
"""
|
||||
|
||||
def main(self):
|
||||
"""Empty custom method (callable) inside the mocked WSGI app.
|
||||
|
||||
It has an empty body because we are expecting to verify that
|
||||
the same method is return no the actual execution of it.
|
||||
"""
|
||||
app = WSGIAppMock()
|
||||
# patch sys.modules, to include the an instance of WSGIAppMock
|
||||
# under a specific namespace
|
||||
if six.PY2:
|
||||
# python2 requires the previous namespaces to be part of sys.modules
|
||||
# (e.g. for 'a.b.c' we need to insert 'a', 'a.b' and 'a.b.c')
|
||||
# otherwise it fails, we're setting the same instance on each level,
|
||||
# we don't really care about those, just the last one.
|
||||
monkeypatch.setitem(sys.modules, 'mypkg', app)
|
||||
monkeypatch.setitem(sys.modules, 'mypkg.wsgi', app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('app_name', 'app_method'),
|
||||
(
|
||||
(None, 'application'),
|
||||
('application', 'application'),
|
||||
('main', 'main'),
|
||||
),
|
||||
)
|
||||
# pylint: disable=invalid-name
|
||||
def test_Aplication_resolve(app_name, app_method, wsgi_app):
|
||||
"""Check the wsgi application name conversion."""
|
||||
if app_name is None:
|
||||
wsgi_app_spec = 'mypkg.wsgi'
|
||||
else:
|
||||
wsgi_app_spec = 'mypkg.wsgi:{app_name}'.format(**locals())
|
||||
expected_app = getattr(wsgi_app, app_method)
|
||||
assert Application.resolve(wsgi_app_spec).wsgi_app == expected_app
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
"""Tests for managing HTTP issues (malformed requests, etc)."""
|
||||
# -*- coding: utf-8 -*-
|
||||
# vim: set fileencoding=utf-8 :
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import errno
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
import six
|
||||
from six.moves import urllib
|
||||
|
||||
from cheroot.test import helper
|
||||
|
||||
|
||||
HTTP_BAD_REQUEST = 400
|
||||
HTTP_LENGTH_REQUIRED = 411
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_REQUEST_ENTITY_TOO_LARGE = 413
|
||||
HTTP_OK = 200
|
||||
HTTP_VERSION_NOT_SUPPORTED = 505
|
||||
|
||||
|
||||
class HelloController(helper.Controller):
|
||||
"""Controller for serving WSGI apps."""
|
||||
|
||||
def hello(req, resp):
|
||||
"""Render Hello world."""
|
||||
return 'Hello world!'
|
||||
|
||||
def body_required(req, resp):
|
||||
"""Render Hello world or set 411."""
|
||||
if req.environ.get('Content-Length', None) is None:
|
||||
resp.status = '411 Length Required'
|
||||
return
|
||||
return 'Hello world!'
|
||||
|
||||
def query_string(req, resp):
|
||||
"""Render QUERY_STRING value."""
|
||||
return req.environ.get('QUERY_STRING', '')
|
||||
|
||||
def asterisk(req, resp):
|
||||
"""Render request method value."""
|
||||
# pylint: disable=possibly-unused-variable
|
||||
method = req.environ.get('REQUEST_METHOD', 'NO METHOD FOUND')
|
||||
tmpl = 'Got asterisk URI path with {method} method'
|
||||
return tmpl.format(**locals())
|
||||
|
||||
def _munge(string):
|
||||
"""Encode PATH_INFO correctly depending on Python version.
|
||||
|
||||
WSGI 1.0 is a mess around unicode. Create endpoints
|
||||
that match the PATH_INFO that it produces.
|
||||
"""
|
||||
if six.PY2:
|
||||
return string
|
||||
return string.encode('utf-8').decode('latin-1')
|
||||
|
||||
handlers = {
|
||||
'/hello': hello,
|
||||
'/no_body': hello,
|
||||
'/body_required': body_required,
|
||||
'/query_string': query_string,
|
||||
_munge('/привіт'): hello,
|
||||
_munge('/Юххууу'): hello,
|
||||
'/\xa0Ðblah key 0 900 4 data': hello,
|
||||
'/*': asterisk,
|
||||
}
|
||||
|
||||
|
||||
def _get_http_response(connection, method='GET'):
|
||||
c = connection
|
||||
kwargs = {'strict': c.strict} if hasattr(c, 'strict') else {}
|
||||
# Python 3.2 removed the 'strict' feature, saying:
|
||||
# "http.client now always assumes HTTP/1.x compliant servers."
|
||||
return c.response_class(c.sock, method=method, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testing_server(wsgi_server_client):
|
||||
"""Attach a WSGI app to the given server and preconfigure it."""
|
||||
wsgi_server = wsgi_server_client.server_instance
|
||||
wsgi_server.wsgi_app = HelloController()
|
||||
wsgi_server.max_request_body_size = 30000000
|
||||
wsgi_server.server_client = wsgi_server_client
|
||||
return wsgi_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(testing_server):
|
||||
"""Get and return a test client out of the given server."""
|
||||
return testing_server.server_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testing_server_with_defaults(wsgi_server_client):
|
||||
"""Attach a WSGI app to the given server and preconfigure it."""
|
||||
wsgi_server = wsgi_server_client.server_instance
|
||||
wsgi_server.wsgi_app = HelloController()
|
||||
wsgi_server.server_client = wsgi_server_client
|
||||
return wsgi_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_with_defaults(testing_server_with_defaults):
|
||||
"""Get and return a test client out of the given server."""
|
||||
return testing_server_with_defaults.server_client
|
||||
|
||||
|
||||
def test_http_connect_request(test_client):
|
||||
"""Check that CONNECT query results in Method Not Allowed status."""
|
||||
status_line = test_client.connect('/anything')[0]
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == 405
|
||||
|
||||
|
||||
def test_normal_request(test_client):
|
||||
"""Check that normal GET query succeeds."""
|
||||
status_line, _, actual_resp_body = test_client.get('/hello')
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_OK
|
||||
assert actual_resp_body == b'Hello world!'
|
||||
|
||||
|
||||
def test_query_string_request(test_client):
|
||||
"""Check that GET param is parsed well."""
|
||||
status_line, _, actual_resp_body = test_client.get(
|
||||
'/query_string?test=True',
|
||||
)
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_OK
|
||||
assert actual_resp_body == b'test=True'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'uri',
|
||||
(
|
||||
'/hello', # plain
|
||||
'/query_string?test=True', # query
|
||||
'/{0}?{1}={2}'.format( # quoted unicode
|
||||
*map(urllib.parse.quote, ('Юххууу', 'ї', 'йо'))
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_parse_acceptable_uri(test_client, uri):
|
||||
"""Check that server responds with OK to valid GET queries."""
|
||||
status_line = test_client.get(uri)[0]
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_OK
|
||||
|
||||
|
||||
@pytest.mark.xfail(six.PY2, reason='Fails on Python 2')
|
||||
def test_parse_uri_unsafe_uri(test_client):
|
||||
"""Test that malicious URI does not allow HTTP injection.
|
||||
|
||||
This effectively checks that sending GET request with URL
|
||||
|
||||
/%A0%D0blah%20key%200%20900%204%20data
|
||||
|
||||
is not converted into
|
||||
|
||||
GET /
|
||||
blah key 0 900 4 data
|
||||
HTTP/1.1
|
||||
|
||||
which would be a security issue otherwise.
|
||||
"""
|
||||
c = test_client.get_connection()
|
||||
resource = '/\xa0Ðblah key 0 900 4 data'.encode('latin-1')
|
||||
quoted = urllib.parse.quote(resource)
|
||||
assert quoted == '/%A0%D0blah%20key%200%20900%204%20data'
|
||||
request = 'GET {quoted} HTTP/1.1'.format(**locals())
|
||||
c._output(request.encode('utf-8'))
|
||||
c._send_output()
|
||||
response = _get_http_response(c, method='GET')
|
||||
response.begin()
|
||||
assert response.status == HTTP_OK
|
||||
assert response.read(12) == b'Hello world!'
|
||||
c.close()
|
||||
|
||||
|
||||
def test_parse_uri_invalid_uri(test_client):
|
||||
"""Check that server responds with Bad Request to invalid GET queries.
|
||||
|
||||
Invalid request line test case: it should only contain US-ASCII.
|
||||
"""
|
||||
c = test_client.get_connection()
|
||||
c._output(u'GET /йопта! HTTP/1.1'.encode('utf-8'))
|
||||
c._send_output()
|
||||
response = _get_http_response(c, method='GET')
|
||||
response.begin()
|
||||
assert response.status == HTTP_BAD_REQUEST
|
||||
assert response.read(21) == b'Malformed Request-URI'
|
||||
c.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'uri',
|
||||
(
|
||||
'hello', # ascii
|
||||
'привіт', # non-ascii
|
||||
),
|
||||
)
|
||||
def test_parse_no_leading_slash_invalid(test_client, uri):
|
||||
"""Check that server responds with Bad Request to invalid GET queries.
|
||||
|
||||
Invalid request line test case: it should have leading slash (be absolute).
|
||||
"""
|
||||
status_line, _, actual_resp_body = test_client.get(
|
||||
urllib.parse.quote(uri),
|
||||
)
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_BAD_REQUEST
|
||||
assert b'starting with a slash' in actual_resp_body
|
||||
|
||||
|
||||
def test_parse_uri_absolute_uri(test_client):
|
||||
"""Check that server responds with Bad Request to Absolute URI.
|
||||
|
||||
Only proxy servers should allow this.
|
||||
"""
|
||||
status_line, _, actual_resp_body = test_client.get('http://google.com/')
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_BAD_REQUEST
|
||||
expected_body = b'Absolute URI not allowed if server is not a proxy.'
|
||||
assert actual_resp_body == expected_body
|
||||
|
||||
|
||||
def test_parse_uri_asterisk_uri(test_client):
|
||||
"""Check that server responds with OK to OPTIONS with "*" Absolute URI."""
|
||||
status_line, _, actual_resp_body = test_client.options('*')
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_OK
|
||||
expected_body = b'Got asterisk URI path with OPTIONS method'
|
||||
assert actual_resp_body == expected_body
|
||||
|
||||
|
||||
def test_parse_uri_fragment_uri(test_client):
|
||||
"""Check that server responds with Bad Request to URI with fragment."""
|
||||
status_line, _, actual_resp_body = test_client.get(
|
||||
'/hello?test=something#fake',
|
||||
)
|
||||
actual_status = int(status_line[:3])
|
||||
assert actual_status == HTTP_BAD_REQUEST
|
||||
expected_body = b'Illegal #fragment in Request-URI.'
|
||||
assert actual_resp_body == expected_body
|
||||
|
||||
|
||||
def test_no_content_length(test_client):
|
||||
"""Test POST query with an empty body being successful."""
|
||||
# "The presence of a message-body in a request is signaled by the
|
||||
# inclusion of a Content-Length or Transfer-Encoding header field in
|
||||
# the request's message-headers."
|
||||
#
|
||||
# Send a message with neither header and no body.
|
||||
c = test_client.get_connection()
|
||||
c.request('POST', '/no_body')
|
||||
response = c.getresponse()
|
||||
actual_resp_body = response.read()
|
||||
actual_status = response.status
|
||||
assert actual_status == HTTP_OK
|
||||
assert actual_resp_body == b'Hello world!'
|
||||
|
||||
|
||||
def test_content_length_required(test_client):
|
||||
"""Test POST query with body failing because of missing Content-Length."""
|
||||
# Now send a message that has no Content-Length, but does send a body.
|
||||
# Verify that CP times out the socket and responds
|
||||
# with 411 Length Required.
|
||||
|
||||
c = test_client.get_connection()
|
||||
c.request('POST', '/body_required')
|
||||
response = c.getresponse()
|
||||
response.read()
|
||||
|
||||
actual_status = response.status
|
||||
assert actual_status == HTTP_LENGTH_REQUIRED
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason='https://github.com/cherrypy/cheroot/issues/106',
|
||||
strict=False, # sometimes it passes
|
||||
)
|
||||
def test_large_request(test_client_with_defaults):
|
||||
"""Test GET query with maliciously large Content-Length."""
|
||||
# If the server's max_request_body_size is not set (i.e. is set to 0)
|
||||
# then this will result in an `OverflowError: Python int too large to
|
||||
# convert to C ssize_t` in the server.
|
||||
# We expect that this should instead return that the request is too
|
||||
# large.
|
||||
c = test_client_with_defaults.get_connection()
|
||||
c.putrequest('GET', '/hello')
|
||||
c.putheader('Content-Length', str(2**64))
|
||||
c.endheaders()
|
||||
|
||||
response = c.getresponse()
|
||||
actual_status = response.status
|
||||
|
||||
assert actual_status == HTTP_REQUEST_ENTITY_TOO_LARGE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('request_line', 'status_code', 'expected_body'),
|
||||
(
|
||||
(
|
||||
b'GET /', # missing proto
|
||||
HTTP_BAD_REQUEST, b'Malformed Request-Line',
|
||||
),
|
||||
(
|
||||
b'GET / HTTPS/1.1', # invalid proto
|
||||
HTTP_BAD_REQUEST, b'Malformed Request-Line: bad protocol',
|
||||
),
|
||||
(
|
||||
b'GET / HTTP/1', # invalid version
|
||||
HTTP_BAD_REQUEST, b'Malformed Request-Line: bad version',
|
||||
),
|
||||
(
|
||||
b'GET / HTTP/2.15', # invalid ver
|
||||
HTTP_VERSION_NOT_SUPPORTED, b'Cannot fulfill request',
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_malformed_request_line(
|
||||
test_client, request_line,
|
||||
status_code, expected_body,
|
||||
):
|
||||
"""Test missing or invalid HTTP version in Request-Line."""
|
||||
c = test_client.get_connection()
|
||||
c._output(request_line)
|
||||
c._send_output()
|
||||
response = _get_http_response(c, method='GET')
|
||||
response.begin()
|
||||
assert response.status == status_code
|
||||
assert response.read(len(expected_body)) == expected_body
|
||||
c.close()
|
||||
|
||||
|
||||
def test_malformed_http_method(test_client):
|
||||
"""Test non-uppercase HTTP method."""
|
||||
c = test_client.get_connection()
|
||||
c.putrequest('GeT', '/malformed_method_case')
|
||||
c.putheader('Content-Type', 'text/plain')
|
||||
c.endheaders()
|
||||
|
||||
response = c.getresponse()
|
||||
actual_status = response.status
|
||||
assert actual_status == HTTP_BAD_REQUEST
|
||||
actual_resp_body = response.read(21)
|
||||
assert actual_resp_body == b'Malformed method name'
|
||||
|
||||
|
||||
def test_malformed_header(test_client):
|
||||
"""Check that broken HTTP header results in Bad Request."""
|
||||
c = test_client.get_connection()
|
||||
c.putrequest('GET', '/')
|
||||
c.putheader('Content-Type', 'text/plain')
|
||||
# See https://www.bitbucket.org/cherrypy/cherrypy/issue/941
|
||||
c._output(b'Re, 1.2.3.4#015#012')
|
||||
c.endheaders()
|
||||
|
||||
response = c.getresponse()
|
||||
actual_status = response.status
|
||||
assert actual_status == HTTP_BAD_REQUEST
|
||||
actual_resp_body = response.read(20)
|
||||
assert actual_resp_body == b'Illegal header line.'
|
||||
|
||||
|
||||
def test_request_line_split_issue_1220(test_client):
|
||||
"""Check that HTTP request line of exactly 256 chars length is OK."""
|
||||
Request_URI = (
|
||||
'/hello?'
|
||||
'intervenant-entreprise-evenement_classaction='
|
||||
'evenement-mailremerciements'
|
||||
'&_path=intervenant-entreprise-evenement'
|
||||
'&intervenant-entreprise-evenement_action-id=19404'
|
||||
'&intervenant-entreprise-evenement_id=19404'
|
||||
'&intervenant-entreprise_id=28092'
|
||||
)
|
||||
assert len('GET %s HTTP/1.1\r\n' % Request_URI) == 256
|
||||
|
||||
actual_resp_body = test_client.get(Request_URI)[2]
|
||||
assert actual_resp_body == b'Hello world!'
|
||||
|
||||
|
||||
def test_garbage_in(test_client):
|
||||
"""Test that server sends an error for garbage received over TCP."""
|
||||
# Connect without SSL regardless of server.scheme
|
||||
|
||||
c = test_client.get_connection()
|
||||
c._output(b'gjkgjklsgjklsgjkljklsg')
|
||||
c._send_output()
|
||||
response = c.response_class(c.sock, method='GET')
|
||||
try:
|
||||
response.begin()
|
||||
actual_status = response.status
|
||||
assert actual_status == HTTP_BAD_REQUEST
|
||||
actual_resp_body = response.read(22)
|
||||
assert actual_resp_body == b'Malformed Request-Line'
|
||||
c.close()
|
||||
except socket.error as ex:
|
||||
# "Connection reset by peer" is also acceptable.
|
||||
if ex.errno != errno.ECONNRESET:
|
||||
raise
|
||||
|
||||
|
||||
class CloseController:
|
||||
"""Controller for testing the close callback."""
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
"""Get the req to know header sent status."""
|
||||
self.req = start_response.__self__.req
|
||||
resp = CloseResponse(self.close)
|
||||
start_response(resp.status, resp.headers.items())
|
||||
return resp
|
||||
|
||||
def close(self):
|
||||
"""Close, writing hello."""
|
||||
self.req.write(b'hello')
|
||||
|
||||
|
||||
class CloseResponse:
|
||||
"""Dummy empty response to trigger the no body status."""
|
||||
|
||||
def __init__(self, close):
|
||||
"""Use some defaults to ensure we have a header."""
|
||||
self.status = '200 OK'
|
||||
self.headers = {'Content-Type': 'text/html'}
|
||||
self.close = close
|
||||
|
||||
def __getitem__(self, index):
|
||||
"""Ensure we don't have a body."""
|
||||
raise IndexError()
|
||||
|
||||
def output(self):
|
||||
"""Return self to hook the close method."""
|
||||
return self
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testing_server_close(wsgi_server_client):
|
||||
"""Attach a WSGI app to the given server and preconfigure it."""
|
||||
wsgi_server = wsgi_server_client.server_instance
|
||||
wsgi_server.wsgi_app = CloseController()
|
||||
wsgi_server.max_request_body_size = 30000000
|
||||
wsgi_server.server_client = wsgi_server_client
|
||||
return wsgi_server
|
||||
|
||||
|
||||
def test_send_header_before_closing(testing_server_close):
|
||||
"""Test we are actually sending the headers before calling 'close'."""
|
||||
_, _, resp_body = testing_server_close.server_client.get('/')
|
||||
assert resp_body == b'hello'
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for the HTTP server."""
|
||||
# -*- coding: utf-8 -*-
|
||||
# vim: set fileencoding=utf-8 :
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
from cheroot.wsgi import PathInfoDispatcher
|
||||
|
||||
|
||||
def wsgi_invoke(app, environ):
|
||||
"""Serve 1 request from a WSGI application."""
|
||||
response = {}
|
||||
|
||||
def start_response(status, headers):
|
||||
response.update({
|
||||
'status': status,
|
||||
'headers': headers,
|
||||
})
|
||||
|
||||
response['body'] = b''.join(
|
||||
app(environ, start_response),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def test_dispatch_no_script_name():
|
||||
"""Dispatch despite lack of ``SCRIPT_NAME`` in environ."""
|
||||
# Bare bones WSGI hello world app (from PEP 333).
|
||||
def app(environ, start_response):
|
||||
start_response(
|
||||
'200 OK', [
|
||||
('Content-Type', 'text/plain; charset=utf-8'),
|
||||
],
|
||||
)
|
||||
return [u'Hello, world!'.encode('utf-8')]
|
||||
|
||||
# Build a dispatch table.
|
||||
d = PathInfoDispatcher([
|
||||
('/', app),
|
||||
])
|
||||
|
||||
# Dispatch a request without `SCRIPT_NAME`.
|
||||
response = wsgi_invoke(
|
||||
d, {
|
||||
'PATH_INFO': '/foo',
|
||||
},
|
||||
)
|
||||
assert response == {
|
||||
'status': '200 OK',
|
||||
'headers': [
|
||||
('Content-Type', 'text/plain; charset=utf-8'),
|
||||
],
|
||||
'body': b'Hello, world!',
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Test suite for ``cheroot.errors``."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cheroot import errors
|
||||
|
||||
from .._compat import IS_LINUX, IS_MACOS, IS_WINDOWS # noqa: WPS130
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('err_names', 'err_nums'),
|
||||
(
|
||||
(('', 'some-nonsense-name'), []),
|
||||
(
|
||||
(
|
||||
'EPROTOTYPE', 'EAGAIN', 'EWOULDBLOCK',
|
||||
'WSAEWOULDBLOCK', 'EPIPE',
|
||||
),
|
||||
(91, 11, 32) if IS_LINUX else
|
||||
(32, 35, 41) if IS_MACOS else
|
||||
(32, 10041, 11, 10035) if IS_WINDOWS else
|
||||
(),
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_plat_specific_errors(err_names, err_nums):
|
||||
"""Test that ``plat_specific_errors`` gets correct error numbers list."""
|
||||
actual_err_nums = errors.plat_specific_errors(*err_names)
|
||||
assert len(actual_err_nums) == len(err_nums)
|
||||
assert sorted(actual_err_nums) == sorted(err_nums)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for :py:mod:`cheroot.makefile`."""
|
||||
|
||||
from cheroot import makefile
|
||||
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
class MockSocket:
|
||||
"""A mock socket."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize :py:class:`MockSocket`."""
|
||||
self.messages = []
|
||||
|
||||
def recv_into(self, buf):
|
||||
"""Simulate ``recv_into`` for Python 3."""
|
||||
if not self.messages:
|
||||
return 0
|
||||
msg = self.messages.pop(0)
|
||||
for index, byte in enumerate(msg):
|
||||
buf[index] = byte
|
||||
return len(msg)
|
||||
|
||||
def recv(self, size):
|
||||
"""Simulate ``recv`` for Python 2."""
|
||||
try:
|
||||
return self.messages.pop(0)
|
||||
except IndexError:
|
||||
return ''
|
||||
|
||||
def send(self, val):
|
||||
"""Simulate a send."""
|
||||
return len(val)
|
||||
|
||||
|
||||
def test_bytes_read():
|
||||
"""Reader should capture bytes read."""
|
||||
sock = MockSocket()
|
||||
sock.messages.append(b'foo')
|
||||
rfile = makefile.MakeFile(sock, 'r')
|
||||
rfile.read()
|
||||
assert rfile.bytes_read == 3
|
||||
|
||||
|
||||
def test_bytes_written():
|
||||
"""Writer should capture bytes written."""
|
||||
sock = MockSocket()
|
||||
sock.messages.append(b'foo')
|
||||
wfile = makefile.MakeFile(sock, 'w')
|
||||
wfile.write(b'bar')
|
||||
assert wfile.bytes_written == 3
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Tests for the HTTP server."""
|
||||
# -*- coding: utf-8 -*-
|
||||
# vim: set fileencoding=utf-8 :
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import requests_unixsocket
|
||||
import six
|
||||
|
||||
from pypytools.gc.custom import DefaultGc
|
||||
from six.moves import queue, urllib
|
||||
|
||||
from .._compat import bton, ntob
|
||||
from .._compat import IS_LINUX, IS_MACOS, IS_WINDOWS, SYS_PLATFORM
|
||||
from ..server import IS_UID_GID_RESOLVABLE, Gateway, HTTPServer
|
||||
from ..testing import (
|
||||
ANY_INTERFACE_IPV4,
|
||||
ANY_INTERFACE_IPV6,
|
||||
EPHEMERAL_PORT,
|
||||
)
|
||||
|
||||
|
||||
IS_SLOW_ENV = IS_MACOS or IS_WINDOWS
|
||||
|
||||
|
||||
unix_only_sock_test = pytest.mark.skipif(
|
||||
not hasattr(socket, 'AF_UNIX'),
|
||||
reason='UNIX domain sockets are only available under UNIX-based OS',
|
||||
)
|
||||
|
||||
|
||||
non_macos_sock_test = pytest.mark.skipif(
|
||||
IS_MACOS,
|
||||
reason='Peercreds lookup does not work under macOS/BSD currently.',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=('abstract', 'file'))
|
||||
def unix_sock_file(request):
|
||||
"""Check that bound UNIX socket address is stored in server."""
|
||||
name = 'unix_{request.param}_sock'.format(**locals())
|
||||
return request.getfixturevalue(name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unix_abstract_sock():
|
||||
"""Return an abstract UNIX socket address."""
|
||||
if not IS_LINUX:
|
||||
pytest.skip(
|
||||
'{os} does not support an abstract '
|
||||
'socket namespace'.format(os=SYS_PLATFORM),
|
||||
)
|
||||
return b''.join((
|
||||
b'\x00cheroot-test-socket',
|
||||
ntob(str(uuid.uuid4())),
|
||||
)).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unix_file_sock():
|
||||
"""Yield a unix file socket."""
|
||||
tmp_sock_fh, tmp_sock_fname = tempfile.mkstemp()
|
||||
|
||||
yield tmp_sock_fname
|
||||
|
||||
os.close(tmp_sock_fh)
|
||||
os.unlink(tmp_sock_fname)
|
||||
|
||||
|
||||
def test_prepare_makes_server_ready():
|
||||
"""Check that prepare() makes the server ready, and stop() clears it."""
|
||||
httpserver = HTTPServer(
|
||||
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
|
||||
gateway=Gateway,
|
||||
)
|
||||
|
||||
assert not httpserver.ready
|
||||
assert not httpserver.requests._threads
|
||||
|
||||
httpserver.prepare()
|
||||
|
||||
assert httpserver.ready
|
||||
assert httpserver.requests._threads
|
||||
for thr in httpserver.requests._threads:
|
||||
assert thr.ready
|
||||
|
||||
httpserver.stop()
|
||||
|
||||
assert not httpserver.requests._threads
|
||||
assert not httpserver.ready
|
||||
|
||||
|
||||
def test_stop_interrupts_serve():
|
||||
"""Check that stop() interrupts running of serve()."""
|
||||
httpserver = HTTPServer(
|
||||
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
|
||||
gateway=Gateway,
|
||||
)
|
||||
|
||||
httpserver.prepare()
|
||||
serve_thread = threading.Thread(target=httpserver.serve)
|
||||
serve_thread.start()
|
||||
|
||||
serve_thread.join(0.5)
|
||||
assert serve_thread.is_alive()
|
||||
|
||||
httpserver.stop()
|
||||
|
||||
serve_thread.join(0.5)
|
||||
assert not serve_thread.is_alive()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'exc_cls',
|
||||
(
|
||||
IOError,
|
||||
KeyboardInterrupt,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
),
|
||||
)
|
||||
def test_server_interrupt(exc_cls):
|
||||
"""Check that assigning interrupt stops the server."""
|
||||
interrupt_msg = 'should catch {uuid!s}'.format(uuid=uuid.uuid4())
|
||||
raise_marker_sentinel = object()
|
||||
|
||||
httpserver = HTTPServer(
|
||||
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
|
||||
gateway=Gateway,
|
||||
)
|
||||
|
||||
result_q = queue.Queue()
|
||||
|
||||
def serve_thread():
|
||||
# ensure we catch the exception on the serve() thread
|
||||
try:
|
||||
httpserver.serve()
|
||||
except exc_cls as e:
|
||||
if str(e) == interrupt_msg:
|
||||
result_q.put(raise_marker_sentinel)
|
||||
|
||||
httpserver.prepare()
|
||||
serve_thread = threading.Thread(target=serve_thread)
|
||||
serve_thread.start()
|
||||
|
||||
serve_thread.join(0.5)
|
||||
assert serve_thread.is_alive()
|
||||
|
||||
# this exception is raised on the serve() thread,
|
||||
# not in the calling context.
|
||||
httpserver.interrupt = exc_cls(interrupt_msg)
|
||||
|
||||
serve_thread.join(0.5)
|
||||
assert not serve_thread.is_alive()
|
||||
assert result_q.get_nowait() is raise_marker_sentinel
|
||||
|
||||
|
||||
def test_serving_is_false_and_stop_returns_after_ctrlc():
|
||||
"""Check that stop() interrupts running of serve()."""
|
||||
httpserver = HTTPServer(
|
||||
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
|
||||
gateway=Gateway,
|
||||
)
|
||||
|
||||
httpserver.prepare()
|
||||
|
||||
# Simulate a Ctrl-C on the first call to `run`.
|
||||
def raise_keyboard_interrupt(*args, **kwargs):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
httpserver._connections._selector.select = raise_keyboard_interrupt
|
||||
|
||||
serve_thread = threading.Thread(target=httpserver.serve)
|
||||
serve_thread.start()
|
||||
|
||||
# The thread should exit right away due to the interrupt.
|
||||
serve_thread.join(
|
||||
httpserver.expiration_interval * (4 if IS_SLOW_ENV else 2),
|
||||
)
|
||||
assert not serve_thread.is_alive()
|
||||
|
||||
assert not httpserver._connections._serving
|
||||
httpserver.stop()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'ip_addr',
|
||||
(
|
||||
ANY_INTERFACE_IPV4,
|
||||
ANY_INTERFACE_IPV6,
|
||||
),
|
||||
)
|
||||
def test_bind_addr_inet(http_server, ip_addr):
|
||||
"""Check that bound IP address is stored in server."""
|
||||
httpserver = http_server.send((ip_addr, EPHEMERAL_PORT))
|
||||
|
||||
assert httpserver.bind_addr[0] == ip_addr
|
||||
assert httpserver.bind_addr[1] != EPHEMERAL_PORT
|
||||
|
||||
|
||||
@unix_only_sock_test
|
||||
def test_bind_addr_unix(http_server, unix_sock_file):
|
||||
"""Check that bound UNIX socket address is stored in server."""
|
||||
httpserver = http_server.send(unix_sock_file)
|
||||
|
||||
assert httpserver.bind_addr == unix_sock_file
|
||||
|
||||
|
||||
@unix_only_sock_test
|
||||
def test_bind_addr_unix_abstract(http_server, unix_abstract_sock):
|
||||
"""Check that bound UNIX abstract socket address is stored in server."""
|
||||
httpserver = http_server.send(unix_abstract_sock)
|
||||
|
||||
assert httpserver.bind_addr == unix_abstract_sock
|
||||
|
||||
|
||||
PEERCRED_IDS_URI = '/peer_creds/ids'
|
||||
PEERCRED_TEXTS_URI = '/peer_creds/texts'
|
||||
|
||||
|
||||
class _TestGateway(Gateway):
|
||||
def respond(self):
|
||||
req = self.req
|
||||
conn = req.conn
|
||||
req_uri = bton(req.uri)
|
||||
if req_uri == PEERCRED_IDS_URI:
|
||||
peer_creds = conn.peer_pid, conn.peer_uid, conn.peer_gid
|
||||
self.send_payload('|'.join(map(str, peer_creds)))
|
||||
return
|
||||
elif req_uri == PEERCRED_TEXTS_URI:
|
||||
self.send_payload('!'.join((conn.peer_user, conn.peer_group)))
|
||||
return
|
||||
return super(_TestGateway, self).respond()
|
||||
|
||||
def send_payload(self, payload):
|
||||
req = self.req
|
||||
req.status = b'200 OK'
|
||||
req.ensure_headers_sent()
|
||||
req.write(ntob(payload))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def peercreds_enabled_server(http_server, unix_sock_file):
|
||||
"""Construct a test server with ``peercreds_enabled``."""
|
||||
httpserver = http_server.send(unix_sock_file)
|
||||
httpserver.gateway = _TestGateway
|
||||
httpserver.peercreds_enabled = True
|
||||
return httpserver
|
||||
|
||||
|
||||
@unix_only_sock_test
|
||||
@non_macos_sock_test
|
||||
def test_peercreds_unix_sock(peercreds_enabled_server):
|
||||
"""Check that ``PEERCRED`` lookup works when enabled."""
|
||||
httpserver = peercreds_enabled_server
|
||||
bind_addr = httpserver.bind_addr
|
||||
|
||||
if isinstance(bind_addr, six.binary_type):
|
||||
bind_addr = bind_addr.decode()
|
||||
|
||||
# pylint: disable=possibly-unused-variable
|
||||
quoted = urllib.parse.quote(bind_addr, safe='')
|
||||
unix_base_uri = 'http+unix://{quoted}'.format(**locals())
|
||||
|
||||
expected_peercreds = os.getpid(), os.getuid(), os.getgid()
|
||||
expected_peercreds = '|'.join(map(str, expected_peercreds))
|
||||
|
||||
with requests_unixsocket.monkeypatch():
|
||||
peercreds_resp = requests.get(unix_base_uri + PEERCRED_IDS_URI)
|
||||
peercreds_resp.raise_for_status()
|
||||
assert peercreds_resp.text == expected_peercreds
|
||||
|
||||
peercreds_text_resp = requests.get(unix_base_uri + PEERCRED_TEXTS_URI)
|
||||
assert peercreds_text_resp.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not IS_UID_GID_RESOLVABLE,
|
||||
reason='Modules `grp` and `pwd` are not available '
|
||||
'under the current platform',
|
||||
)
|
||||
@unix_only_sock_test
|
||||
@non_macos_sock_test
|
||||
def test_peercreds_unix_sock_with_lookup(peercreds_enabled_server):
|
||||
"""Check that ``PEERCRED`` resolution works when enabled."""
|
||||
httpserver = peercreds_enabled_server
|
||||
httpserver.peercreds_resolve_enabled = True
|
||||
|
||||
bind_addr = httpserver.bind_addr
|
||||
|
||||
if isinstance(bind_addr, six.binary_type):
|
||||
bind_addr = bind_addr.decode()
|
||||
|
||||
# pylint: disable=possibly-unused-variable
|
||||
quoted = urllib.parse.quote(bind_addr, safe='')
|
||||
unix_base_uri = 'http+unix://{quoted}'.format(**locals())
|
||||
|
||||
import grp
|
||||
import pwd
|
||||
expected_textcreds = (
|
||||
pwd.getpwuid(os.getuid()).pw_name,
|
||||
grp.getgrgid(os.getgid()).gr_name,
|
||||
)
|
||||
expected_textcreds = '!'.join(map(str, expected_textcreds))
|
||||
with requests_unixsocket.monkeypatch():
|
||||
peercreds_text_resp = requests.get(unix_base_uri + PEERCRED_TEXTS_URI)
|
||||
peercreds_text_resp.raise_for_status()
|
||||
assert peercreds_text_resp.text == expected_textcreds
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
IS_WINDOWS,
|
||||
reason='This regression test is for a Linux bug, '
|
||||
'and the resource module is not available on Windows',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
'resource_limit',
|
||||
(
|
||||
1024,
|
||||
2048,
|
||||
),
|
||||
indirect=('resource_limit',),
|
||||
)
|
||||
@pytest.mark.usefixtures('many_open_sockets')
|
||||
def test_high_number_of_file_descriptors(native_server_client, resource_limit):
|
||||
"""Test the server does not crash with a high file-descriptor value.
|
||||
|
||||
This test shouldn't cause a server crash when trying to access
|
||||
file-descriptor higher than 1024.
|
||||
|
||||
The earlier implementation used to rely on ``select()`` syscall that
|
||||
doesn't support file descriptors with numbers higher than 1024.
|
||||
"""
|
||||
# We want to force the server to use a file-descriptor with
|
||||
# a number above resource_limit
|
||||
|
||||
# Patch the method that processes
|
||||
_old_process_conn = native_server_client.server_instance.process_conn
|
||||
|
||||
def native_process_conn(conn):
|
||||
native_process_conn.filenos.add(conn.socket.fileno())
|
||||
return _old_process_conn(conn)
|
||||
native_process_conn.filenos = set()
|
||||
native_server_client.server_instance.process_conn = native_process_conn
|
||||
|
||||
# Trigger a crash if select() is used in the implementation
|
||||
native_server_client.connect('/')
|
||||
|
||||
# Ensure that at least one connection got accepted, otherwise the
|
||||
# follow-up check wouldn't make sense
|
||||
assert len(native_process_conn.filenos) > 0
|
||||
|
||||
# Check at least one of the sockets created are above the target number
|
||||
assert any(fn >= resource_limit for fn in native_process_conn.filenos)
|
||||
|
||||
|
||||
if not IS_WINDOWS:
|
||||
test_high_number_of_file_descriptors = pytest.mark.forked(
|
||||
test_high_number_of_file_descriptors,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _garbage_bin():
|
||||
"""Disable garbage collection when this fixture is in use."""
|
||||
with DefaultGc().nogc():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_limit(request):
|
||||
"""Set the resource limit two times bigger then requested."""
|
||||
resource = pytest.importorskip(
|
||||
'resource',
|
||||
reason='The "resource" module is Unix-specific',
|
||||
)
|
||||
|
||||
# Get current resource limits to restore them later
|
||||
soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
|
||||
# We have to increase the nofile limit above 1024
|
||||
# Otherwise we see a 'Too many files open' error, instead of
|
||||
# an error due to the file descriptor number being too high
|
||||
resource.setrlimit(
|
||||
resource.RLIMIT_NOFILE,
|
||||
(request.param * 2, hard_limit),
|
||||
)
|
||||
|
||||
try: # noqa: WPS501
|
||||
yield request.param
|
||||
finally:
|
||||
# Reset the resource limit back to the original soft limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (soft_limit, hard_limit))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def many_open_sockets(request, resource_limit):
|
||||
"""Allocate a lot of file descriptors by opening dummy sockets."""
|
||||
# NOTE: `@pytest.mark.usefixtures` doesn't work on fixtures which
|
||||
# NOTE: forces us to invoke this one dynamically to avoid having an
|
||||
# NOTE: unused argument.
|
||||
request.getfixturevalue('_garbage_bin')
|
||||
|
||||
# Hoard a lot of file descriptors by opening and storing a lot of sockets
|
||||
test_sockets = []
|
||||
# Open a lot of file descriptors, so the next one the server
|
||||
# opens is a high number
|
||||
try:
|
||||
for _ in range(resource_limit):
|
||||
sock = socket.socket()
|
||||
test_sockets.append(sock)
|
||||
# If we reach a high enough number, we don't need to open more
|
||||
if sock.fileno() >= resource_limit:
|
||||
break
|
||||
# Check we opened enough descriptors to reach a high number
|
||||
the_highest_fileno = test_sockets[-1].fileno()
|
||||
assert the_highest_fileno >= resource_limit
|
||||
yield the_highest_fileno
|
||||
finally:
|
||||
# Close our open resources
|
||||
for test_socket in test_sockets:
|
||||
test_socket.close()
|
||||
@@ -0,0 +1,763 @@
|
||||
"""Tests for TLS support."""
|
||||
# -*- coding: utf-8 -*-
|
||||
# vim: set fileencoding=utf-8 :
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import OpenSSL.SSL
|
||||
import pytest
|
||||
import requests
|
||||
import six
|
||||
import trustme
|
||||
|
||||
from .._compat import bton, ntob, ntou
|
||||
from .._compat import IS_ABOVE_OPENSSL10, IS_CI, IS_PYPY
|
||||
from .._compat import IS_LINUX, IS_MACOS, IS_WINDOWS
|
||||
from ..server import HTTPServer, get_ssl_adapter_class
|
||||
from ..testing import (
|
||||
ANY_INTERFACE_IPV4,
|
||||
ANY_INTERFACE_IPV6,
|
||||
EPHEMERAL_PORT,
|
||||
# get_server_client,
|
||||
_get_conn_data,
|
||||
_probe_ipv6_sock,
|
||||
)
|
||||
from ..wsgi import Gateway_10
|
||||
|
||||
|
||||
IS_GITHUB_ACTIONS_WORKFLOW = bool(os.getenv('GITHUB_WORKFLOW'))
|
||||
IS_WIN2016 = (
|
||||
IS_WINDOWS
|
||||
# pylint: disable=unsupported-membership-test
|
||||
and b'Microsoft Windows Server 2016 Datacenter' in subprocess.check_output(
|
||||
('systeminfo',),
|
||||
)
|
||||
)
|
||||
IS_LIBRESSL_BACKEND = ssl.OPENSSL_VERSION.startswith('LibreSSL')
|
||||
IS_PYOPENSSL_SSL_VERSION_1_0 = (
|
||||
OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION).
|
||||
startswith(b'OpenSSL 1.0.')
|
||||
)
|
||||
PY27 = sys.version_info[:2] == (2, 7)
|
||||
PY34 = sys.version_info[:2] == (3, 4)
|
||||
PY3 = not six.PY2
|
||||
PY310_PLUS = sys.version_info[:2] >= (3, 10)
|
||||
|
||||
|
||||
_stdlib_to_openssl_verify = {
|
||||
ssl.CERT_NONE: OpenSSL.SSL.VERIFY_NONE,
|
||||
ssl.CERT_OPTIONAL: OpenSSL.SSL.VERIFY_PEER,
|
||||
ssl.CERT_REQUIRED:
|
||||
OpenSSL.SSL.VERIFY_PEER + OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT,
|
||||
}
|
||||
|
||||
|
||||
fails_under_py3 = pytest.mark.xfail(
|
||||
not six.PY2,
|
||||
reason='Fails under Python 3+',
|
||||
)
|
||||
|
||||
|
||||
fails_under_py3_in_pypy = pytest.mark.xfail(
|
||||
not six.PY2 and IS_PYPY,
|
||||
reason='Fails under PyPy3',
|
||||
)
|
||||
|
||||
|
||||
missing_ipv6 = pytest.mark.skipif(
|
||||
not _probe_ipv6_sock('::1'),
|
||||
reason=''
|
||||
'IPv6 is disabled '
|
||||
'(for example, under Travis CI '
|
||||
'which runs under GCE supporting only IPv4)',
|
||||
)
|
||||
|
||||
|
||||
class HelloWorldGateway(Gateway_10):
|
||||
"""Gateway responding with Hello World to root URI."""
|
||||
|
||||
def respond(self):
|
||||
"""Respond with dummy content via HTTP."""
|
||||
req = self.req
|
||||
req_uri = bton(req.uri)
|
||||
if req_uri == '/':
|
||||
req.status = b'200 OK'
|
||||
req.ensure_headers_sent()
|
||||
req.write(b'Hello world!')
|
||||
return
|
||||
if req_uri == '/env':
|
||||
req.status = b'200 OK'
|
||||
req.ensure_headers_sent()
|
||||
env = self.get_environ()
|
||||
# drop files so that it can be json dumped
|
||||
env.pop('wsgi.errors')
|
||||
env.pop('wsgi.input')
|
||||
print(env)
|
||||
req.write(json.dumps(env).encode('utf-8'))
|
||||
return
|
||||
return super(HelloWorldGateway, self).respond()
|
||||
|
||||
|
||||
def make_tls_http_server(bind_addr, ssl_adapter, request):
|
||||
"""Create and start an HTTP server bound to ``bind_addr``."""
|
||||
httpserver = HTTPServer(
|
||||
bind_addr=bind_addr,
|
||||
gateway=HelloWorldGateway,
|
||||
)
|
||||
# httpserver.gateway = HelloWorldGateway
|
||||
httpserver.ssl_adapter = ssl_adapter
|
||||
|
||||
threading.Thread(target=httpserver.safe_start).start()
|
||||
|
||||
while not httpserver.ready:
|
||||
time.sleep(0.1)
|
||||
|
||||
request.addfinalizer(httpserver.stop)
|
||||
|
||||
return httpserver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_http_server(request):
|
||||
"""Provision a server creator as a fixture."""
|
||||
return functools.partial(make_tls_http_server, request=request)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ca():
|
||||
"""Provide a certificate authority via fixture."""
|
||||
return trustme.CA()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_ca_certificate_pem_path(ca):
|
||||
"""Provide a certificate authority certificate file via fixture."""
|
||||
with ca.cert_pem.tempfile() as ca_cert_pem:
|
||||
yield ca_cert_pem
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_certificate(ca):
|
||||
"""Provide a leaf certificate via fixture."""
|
||||
interface, _host, _port = _get_conn_data(ANY_INTERFACE_IPV4)
|
||||
return ca.issue_cert(ntou(interface))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_certificate_chain_pem_path(tls_certificate):
|
||||
"""Provide a certificate chain PEM file path via fixture."""
|
||||
with tls_certificate.private_key_and_cert_chain_pem.tempfile() as cert_pem:
|
||||
yield cert_pem
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_certificate_private_key_pem_path(tls_certificate):
|
||||
"""Provide a certificate private key PEM file path via fixture."""
|
||||
with tls_certificate.private_key_pem.tempfile() as cert_key_pem:
|
||||
yield cert_key_pem
|
||||
|
||||
|
||||
def _thread_except_hook(exceptions, args):
|
||||
"""Append uncaught exception ``args`` in threads to ``exceptions``."""
|
||||
if issubclass(args.exc_type, SystemExit):
|
||||
return
|
||||
# cannot store the exception, it references the thread's stack
|
||||
exceptions.append((
|
||||
args.exc_type,
|
||||
str(args.exc_value),
|
||||
''.join(
|
||||
traceback.format_exception(
|
||||
args.exc_type, args.exc_value, args.exc_traceback,
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def thread_exceptions():
|
||||
"""Provide a list of uncaught exceptions from threads via a fixture.
|
||||
|
||||
Only catches exceptions on Python 3.8+.
|
||||
The list contains: ``(type, str(value), str(traceback))``
|
||||
"""
|
||||
exceptions = []
|
||||
# Python 3.8+
|
||||
orig_hook = getattr(threading, 'excepthook', None)
|
||||
if orig_hook is not None:
|
||||
threading.excepthook = functools.partial(
|
||||
_thread_except_hook, exceptions,
|
||||
)
|
||||
try:
|
||||
yield exceptions
|
||||
finally:
|
||||
if orig_hook is not None:
|
||||
threading.excepthook = orig_hook
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'adapter_type',
|
||||
(
|
||||
'builtin',
|
||||
'pyopenssl',
|
||||
),
|
||||
)
|
||||
def test_ssl_adapters(
|
||||
tls_http_server, adapter_type,
|
||||
tls_certificate,
|
||||
tls_certificate_chain_pem_path,
|
||||
tls_certificate_private_key_pem_path,
|
||||
tls_ca_certificate_pem_path,
|
||||
):
|
||||
"""Test ability to connect to server via HTTPS using adapters."""
|
||||
interface, _host, port = _get_conn_data(ANY_INTERFACE_IPV4)
|
||||
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
|
||||
tls_adapter = tls_adapter_cls(
|
||||
tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path,
|
||||
)
|
||||
if adapter_type == 'pyopenssl':
|
||||
tls_adapter.context = tls_adapter.get_context()
|
||||
|
||||
tls_certificate.configure_cert(tls_adapter.context)
|
||||
|
||||
tlshttpserver = tls_http_server((interface, port), tls_adapter)
|
||||
|
||||
# testclient = get_server_client(tlshttpserver)
|
||||
# testclient.get('/')
|
||||
|
||||
interface, _host, port = _get_conn_data(
|
||||
tlshttpserver.bind_addr,
|
||||
)
|
||||
|
||||
resp = requests.get(
|
||||
'https://{host!s}:{port!s}/'.format(host=interface, port=port),
|
||||
verify=tls_ca_certificate_pem_path,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == 'Hello world!'
|
||||
|
||||
|
||||
@pytest.mark.parametrize( # noqa: C901 # FIXME
|
||||
'adapter_type',
|
||||
(
|
||||
'builtin',
|
||||
'pyopenssl',
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
('is_trusted_cert', 'tls_client_identity'),
|
||||
(
|
||||
(True, 'localhost'), (True, '127.0.0.1'),
|
||||
(True, '*.localhost'), (True, 'not_localhost'),
|
||||
(False, 'localhost'),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
'tls_verify_mode',
|
||||
(
|
||||
ssl.CERT_NONE, # server shouldn't validate client cert
|
||||
ssl.CERT_OPTIONAL, # same as CERT_REQUIRED in client mode, don't use
|
||||
ssl.CERT_REQUIRED, # server should validate if client cert CA is OK
|
||||
),
|
||||
)
|
||||
@pytest.mark.xfail(
|
||||
IS_PYPY and IS_CI,
|
||||
reason='Fails under PyPy in CI for unknown reason',
|
||||
strict=False,
|
||||
)
|
||||
def test_tls_client_auth( # noqa: C901 # FIXME
|
||||
# FIXME: remove twisted logic, separate tests
|
||||
mocker,
|
||||
tls_http_server, adapter_type,
|
||||
ca,
|
||||
tls_certificate,
|
||||
tls_certificate_chain_pem_path,
|
||||
tls_certificate_private_key_pem_path,
|
||||
tls_ca_certificate_pem_path,
|
||||
is_trusted_cert, tls_client_identity,
|
||||
tls_verify_mode,
|
||||
):
|
||||
"""Verify that client TLS certificate auth works correctly."""
|
||||
test_cert_rejection = (
|
||||
tls_verify_mode != ssl.CERT_NONE
|
||||
and not is_trusted_cert
|
||||
)
|
||||
interface, _host, port = _get_conn_data(ANY_INTERFACE_IPV4)
|
||||
|
||||
client_cert_root_ca = ca if is_trusted_cert else trustme.CA()
|
||||
with mocker.mock_module.patch(
|
||||
'idna.core.ulabel',
|
||||
return_value=ntob(tls_client_identity),
|
||||
):
|
||||
client_cert = client_cert_root_ca.issue_cert(
|
||||
ntou(tls_client_identity),
|
||||
)
|
||||
del client_cert_root_ca
|
||||
|
||||
with client_cert.private_key_and_cert_chain_pem.tempfile() as cl_pem:
|
||||
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
|
||||
tls_adapter = tls_adapter_cls(
|
||||
tls_certificate_chain_pem_path,
|
||||
tls_certificate_private_key_pem_path,
|
||||
)
|
||||
if adapter_type == 'pyopenssl':
|
||||
tls_adapter.context = tls_adapter.get_context()
|
||||
tls_adapter.context.set_verify(
|
||||
_stdlib_to_openssl_verify[tls_verify_mode],
|
||||
lambda conn, cert, errno, depth, preverify_ok: preverify_ok,
|
||||
)
|
||||
else:
|
||||
tls_adapter.context.verify_mode = tls_verify_mode
|
||||
|
||||
ca.configure_trust(tls_adapter.context)
|
||||
tls_certificate.configure_cert(tls_adapter.context)
|
||||
|
||||
tlshttpserver = tls_http_server((interface, port), tls_adapter)
|
||||
|
||||
interface, _host, port = _get_conn_data(tlshttpserver.bind_addr)
|
||||
|
||||
make_https_request = functools.partial(
|
||||
requests.get,
|
||||
'https://{host!s}:{port!s}/'.format(host=interface, port=port),
|
||||
|
||||
# Server TLS certificate verification:
|
||||
verify=tls_ca_certificate_pem_path,
|
||||
|
||||
# Client TLS certificate verification:
|
||||
cert=cl_pem,
|
||||
)
|
||||
|
||||
if not test_cert_rejection:
|
||||
resp = make_https_request()
|
||||
is_req_successful = resp.status_code == 200
|
||||
if (
|
||||
not is_req_successful
|
||||
and IS_PYOPENSSL_SSL_VERSION_1_0
|
||||
and adapter_type == 'builtin'
|
||||
and tls_verify_mode == ssl.CERT_REQUIRED
|
||||
and tls_client_identity == 'localhost'
|
||||
and is_trusted_cert
|
||||
) or PY34:
|
||||
pytest.xfail(
|
||||
'OpenSSL 1.0 has problems with verifying client certs',
|
||||
)
|
||||
assert is_req_successful
|
||||
assert resp.text == 'Hello world!'
|
||||
return
|
||||
|
||||
# xfail some flaky tests
|
||||
# https://github.com/cherrypy/cheroot/issues/237
|
||||
issue_237 = (
|
||||
IS_MACOS
|
||||
and adapter_type == 'builtin'
|
||||
and tls_verify_mode != ssl.CERT_NONE
|
||||
)
|
||||
if issue_237:
|
||||
pytest.xfail('Test sometimes fails')
|
||||
|
||||
expected_ssl_errors = (
|
||||
requests.exceptions.SSLError,
|
||||
OpenSSL.SSL.Error,
|
||||
) if PY34 else (
|
||||
requests.exceptions.SSLError,
|
||||
)
|
||||
if IS_WINDOWS or IS_GITHUB_ACTIONS_WORKFLOW:
|
||||
expected_ssl_errors += requests.exceptions.ConnectionError,
|
||||
with pytest.raises(expected_ssl_errors) as ssl_err:
|
||||
make_https_request()
|
||||
|
||||
if PY34 and isinstance(ssl_err, OpenSSL.SSL.Error):
|
||||
pytest.xfail(
|
||||
'OpenSSL behaves wierdly under Python 3.4 '
|
||||
'because of an outdated urllib3',
|
||||
)
|
||||
|
||||
try:
|
||||
err_text = ssl_err.value.args[0].reason.args[0].args[0]
|
||||
except AttributeError:
|
||||
if PY34:
|
||||
pytest.xfail('OpenSSL behaves wierdly under Python 3.4')
|
||||
elif IS_WINDOWS or IS_GITHUB_ACTIONS_WORKFLOW:
|
||||
err_text = str(ssl_err.value)
|
||||
else:
|
||||
raise
|
||||
|
||||
if isinstance(err_text, int):
|
||||
err_text = str(ssl_err.value)
|
||||
|
||||
expected_substrings = (
|
||||
'sslv3 alert bad certificate' if IS_LIBRESSL_BACKEND
|
||||
else 'tlsv1 alert unknown ca',
|
||||
)
|
||||
if not six.PY2:
|
||||
if IS_MACOS and IS_PYPY and adapter_type == 'pyopenssl':
|
||||
expected_substrings = ('tlsv1 alert unknown ca',)
|
||||
if (
|
||||
tls_verify_mode in (
|
||||
ssl.CERT_REQUIRED,
|
||||
ssl.CERT_OPTIONAL,
|
||||
)
|
||||
and not is_trusted_cert
|
||||
and tls_client_identity == 'localhost'
|
||||
):
|
||||
expected_substrings += (
|
||||
'bad handshake: '
|
||||
"SysCallError(10054, 'WSAECONNRESET')",
|
||||
"('Connection aborted.', "
|
||||
'OSError("(10054, \'WSAECONNRESET\')"))',
|
||||
"('Connection aborted.', "
|
||||
'OSError("(10054, \'WSAECONNRESET\')",))',
|
||||
"('Connection aborted.', "
|
||||
'error("(10054, \'WSAECONNRESET\')",))',
|
||||
"('Connection aborted.', "
|
||||
'ConnectionResetError(10054, '
|
||||
"'An existing connection was forcibly closed "
|
||||
"by the remote host', None, 10054, None))",
|
||||
"('Connection aborted.', "
|
||||
'error(10054, '
|
||||
"'An existing connection was forcibly closed "
|
||||
"by the remote host'))",
|
||||
) if IS_WINDOWS else (
|
||||
"('Connection aborted.', "
|
||||
'OSError("(104, \'ECONNRESET\')"))',
|
||||
"('Connection aborted.', "
|
||||
'OSError("(104, \'ECONNRESET\')",))',
|
||||
"('Connection aborted.', "
|
||||
'error("(104, \'ECONNRESET\')",))',
|
||||
"('Connection aborted.', "
|
||||
"ConnectionResetError(104, 'Connection reset by peer'))",
|
||||
"('Connection aborted.', "
|
||||
"error(104, 'Connection reset by peer'))",
|
||||
) if (
|
||||
IS_GITHUB_ACTIONS_WORKFLOW
|
||||
and IS_LINUX
|
||||
) else (
|
||||
"('Connection aborted.', "
|
||||
"BrokenPipeError(32, 'Broken pipe'))",
|
||||
)
|
||||
|
||||
if PY310_PLUS:
|
||||
# FIXME: Figure out what's happening and correct the problem
|
||||
expected_substrings += (
|
||||
'SSLError(SSLEOFError(8, '
|
||||
"'EOF occurred in violation of protocol (_ssl.c:",
|
||||
)
|
||||
if IS_GITHUB_ACTIONS_WORKFLOW and IS_WINDOWS and PY310_PLUS:
|
||||
expected_substrings += (
|
||||
"('Connection aborted.', "
|
||||
'RemoteDisconnected('
|
||||
"'Remote end closed connection without response'))",
|
||||
)
|
||||
|
||||
assert any(e in err_text for e in expected_substrings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize( # noqa: C901 # FIXME
|
||||
'adapter_type',
|
||||
(
|
||||
pytest.param(
|
||||
'builtin',
|
||||
marks=pytest.mark.xfail(
|
||||
IS_GITHUB_ACTIONS_WORKFLOW and IS_MACOS and PY310_PLUS,
|
||||
reason='Unclosed TLS resource warnings happen on macOS '
|
||||
'under Python 3.10',
|
||||
strict=False,
|
||||
),
|
||||
),
|
||||
'pyopenssl',
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
('tls_verify_mode', 'use_client_cert'),
|
||||
(
|
||||
(ssl.CERT_NONE, False),
|
||||
(ssl.CERT_NONE, True),
|
||||
(ssl.CERT_OPTIONAL, False),
|
||||
(ssl.CERT_OPTIONAL, True),
|
||||
(ssl.CERT_REQUIRED, True),
|
||||
),
|
||||
)
|
||||
def test_ssl_env( # noqa: C901 # FIXME
|
||||
thread_exceptions,
|
||||
recwarn,
|
||||
mocker,
|
||||
tls_http_server, adapter_type,
|
||||
ca, tls_verify_mode, tls_certificate,
|
||||
tls_certificate_chain_pem_path,
|
||||
tls_certificate_private_key_pem_path,
|
||||
tls_ca_certificate_pem_path,
|
||||
use_client_cert,
|
||||
):
|
||||
"""Test the SSL environment generated by the SSL adapters."""
|
||||
interface, _host, port = _get_conn_data(ANY_INTERFACE_IPV4)
|
||||
|
||||
with mocker.mock_module.patch(
|
||||
'idna.core.ulabel',
|
||||
return_value=ntob('127.0.0.1'),
|
||||
):
|
||||
client_cert = ca.issue_cert(ntou('127.0.0.1'))
|
||||
|
||||
with client_cert.private_key_and_cert_chain_pem.tempfile() as cl_pem:
|
||||
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
|
||||
tls_adapter = tls_adapter_cls(
|
||||
tls_certificate_chain_pem_path,
|
||||
tls_certificate_private_key_pem_path,
|
||||
)
|
||||
if adapter_type == 'pyopenssl':
|
||||
tls_adapter.context = tls_adapter.get_context()
|
||||
tls_adapter.context.set_verify(
|
||||
_stdlib_to_openssl_verify[tls_verify_mode],
|
||||
lambda conn, cert, errno, depth, preverify_ok: preverify_ok,
|
||||
)
|
||||
else:
|
||||
tls_adapter.context.verify_mode = tls_verify_mode
|
||||
|
||||
ca.configure_trust(tls_adapter.context)
|
||||
tls_certificate.configure_cert(tls_adapter.context)
|
||||
|
||||
tlswsgiserver = tls_http_server((interface, port), tls_adapter)
|
||||
|
||||
interface, _host, port = _get_conn_data(tlswsgiserver.bind_addr)
|
||||
|
||||
resp = requests.get(
|
||||
'https://' + interface + ':' + str(port) + '/env',
|
||||
verify=tls_ca_certificate_pem_path,
|
||||
cert=cl_pem if use_client_cert else None,
|
||||
)
|
||||
if PY34 and resp.status_code != 200:
|
||||
pytest.xfail(
|
||||
'Python 3.4 has problems with verifying client certs',
|
||||
)
|
||||
|
||||
env = json.loads(resp.content.decode('utf-8'))
|
||||
|
||||
# hard coded env
|
||||
assert env['wsgi.url_scheme'] == 'https'
|
||||
assert env['HTTPS'] == 'on'
|
||||
|
||||
# ensure these are present
|
||||
for key in {'SSL_VERSION_INTERFACE', 'SSL_VERSION_LIBRARY'}:
|
||||
assert key in env
|
||||
|
||||
# pyOpenSSL generates the env before the handshake completes
|
||||
if adapter_type == 'pyopenssl':
|
||||
return
|
||||
|
||||
for key in {'SSL_PROTOCOL', 'SSL_CIPHER'}:
|
||||
assert key in env
|
||||
|
||||
# client certificate env
|
||||
if tls_verify_mode == ssl.CERT_NONE or not use_client_cert:
|
||||
assert env['SSL_CLIENT_VERIFY'] == 'NONE'
|
||||
else:
|
||||
assert env['SSL_CLIENT_VERIFY'] == 'SUCCESS'
|
||||
|
||||
with open(cl_pem, 'rt') as f:
|
||||
assert env['SSL_CLIENT_CERT'] in f.read()
|
||||
|
||||
for key in {
|
||||
'SSL_CLIENT_M_VERSION', 'SSL_CLIENT_M_SERIAL',
|
||||
'SSL_CLIENT_I_DN', 'SSL_CLIENT_S_DN',
|
||||
}:
|
||||
assert key in env
|
||||
|
||||
# builtin ssl environment generation may use a loopback socket
|
||||
# ensure no ResourceWarning was raised during the test
|
||||
# NOTE: python 2.7 does not emit ResourceWarning for ssl sockets
|
||||
if IS_PYPY:
|
||||
# NOTE: PyPy doesn't have ResourceWarning
|
||||
# Ref: https://doc.pypy.org/en/latest/cpython_differences.html
|
||||
return
|
||||
for warn in recwarn:
|
||||
if not issubclass(warn.category, ResourceWarning):
|
||||
continue
|
||||
|
||||
# the tests can sporadically generate resource warnings
|
||||
# due to timing issues
|
||||
# all of these sporadic warnings appear to be about socket.socket
|
||||
# and have been observed to come from requests connection pool
|
||||
msg = str(warn.message)
|
||||
if 'socket.socket' in msg:
|
||||
pytest.xfail(
|
||||
'\n'.join((
|
||||
'Sometimes this test fails due to '
|
||||
'a socket.socket ResourceWarning:',
|
||||
msg,
|
||||
)),
|
||||
)
|
||||
pytest.fail(msg)
|
||||
|
||||
# to perform the ssl handshake over that loopback socket,
|
||||
# the builtin ssl environment generation uses a thread
|
||||
for _, _, trace in thread_exceptions:
|
||||
print(trace, file=sys.stderr)
|
||||
assert not thread_exceptions, ': '.join((
|
||||
thread_exceptions[0][0].__name__,
|
||||
thread_exceptions[0][1],
|
||||
))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'ip_addr',
|
||||
(
|
||||
ANY_INTERFACE_IPV4,
|
||||
ANY_INTERFACE_IPV6,
|
||||
),
|
||||
)
|
||||
def test_https_over_http_error(http_server, ip_addr):
|
||||
"""Ensure that connecting over HTTPS to HTTP port is handled."""
|
||||
httpserver = http_server.send((ip_addr, EPHEMERAL_PORT))
|
||||
interface, _host, port = _get_conn_data(httpserver.bind_addr)
|
||||
with pytest.raises(ssl.SSLError) as ssl_err:
|
||||
six.moves.http_client.HTTPSConnection(
|
||||
'{interface}:{port}'.format(
|
||||
interface=interface,
|
||||
port=port,
|
||||
),
|
||||
).request('GET', '/')
|
||||
expected_substring = (
|
||||
'wrong version number' if IS_ABOVE_OPENSSL10
|
||||
else 'unknown protocol'
|
||||
)
|
||||
assert expected_substring in ssl_err.value.args[-1]
|
||||
|
||||
|
||||
http_over_https_error_builtin_marks = []
|
||||
if IS_WINDOWS and six.PY2:
|
||||
http_over_https_error_builtin_marks.append(
|
||||
pytest.mark.flaky(reruns=5, reruns_delay=2),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'adapter_type',
|
||||
(
|
||||
pytest.param(
|
||||
'builtin',
|
||||
marks=http_over_https_error_builtin_marks,
|
||||
),
|
||||
'pyopenssl',
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
'ip_addr',
|
||||
(
|
||||
ANY_INTERFACE_IPV4,
|
||||
pytest.param(ANY_INTERFACE_IPV6, marks=missing_ipv6),
|
||||
),
|
||||
)
|
||||
def test_http_over_https_error(
|
||||
tls_http_server, adapter_type,
|
||||
ca, ip_addr,
|
||||
tls_certificate,
|
||||
tls_certificate_chain_pem_path,
|
||||
tls_certificate_private_key_pem_path,
|
||||
):
|
||||
"""Ensure that connecting over HTTP to HTTPS port is handled."""
|
||||
# disable some flaky tests
|
||||
# https://github.com/cherrypy/cheroot/issues/225
|
||||
issue_225 = (
|
||||
IS_MACOS
|
||||
and adapter_type == 'builtin'
|
||||
)
|
||||
if issue_225:
|
||||
pytest.xfail('Test fails in Travis-CI')
|
||||
|
||||
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
|
||||
tls_adapter = tls_adapter_cls(
|
||||
tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path,
|
||||
)
|
||||
if adapter_type == 'pyopenssl':
|
||||
tls_adapter.context = tls_adapter.get_context()
|
||||
|
||||
tls_certificate.configure_cert(tls_adapter.context)
|
||||
|
||||
interface, _host, port = _get_conn_data(ip_addr)
|
||||
tlshttpserver = tls_http_server((interface, port), tls_adapter)
|
||||
|
||||
interface, _host, port = _get_conn_data(
|
||||
tlshttpserver.bind_addr,
|
||||
)
|
||||
|
||||
fqdn = interface
|
||||
if ip_addr is ANY_INTERFACE_IPV6:
|
||||
fqdn = '[{fqdn}]'.format(**locals())
|
||||
|
||||
expect_fallback_response_over_plain_http = (
|
||||
(
|
||||
adapter_type == 'pyopenssl'
|
||||
and (IS_ABOVE_OPENSSL10 or not six.PY2)
|
||||
)
|
||||
or PY27
|
||||
) or (
|
||||
IS_GITHUB_ACTIONS_WORKFLOW
|
||||
and IS_WINDOWS
|
||||
and six.PY2
|
||||
and not IS_WIN2016
|
||||
)
|
||||
if (
|
||||
IS_GITHUB_ACTIONS_WORKFLOW
|
||||
and IS_WINDOWS
|
||||
and six.PY2
|
||||
and IS_WIN2016
|
||||
and adapter_type == 'builtin'
|
||||
and ip_addr is ANY_INTERFACE_IPV6
|
||||
):
|
||||
expect_fallback_response_over_plain_http = True
|
||||
if (
|
||||
IS_GITHUB_ACTIONS_WORKFLOW
|
||||
and IS_WINDOWS
|
||||
and six.PY2
|
||||
and not IS_WIN2016
|
||||
and adapter_type == 'builtin'
|
||||
and ip_addr is not ANY_INTERFACE_IPV6
|
||||
):
|
||||
expect_fallback_response_over_plain_http = False
|
||||
if expect_fallback_response_over_plain_http:
|
||||
resp = requests.get(
|
||||
'http://{host!s}:{port!s}/'.format(host=fqdn, port=port),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.text == (
|
||||
'The client sent a plain HTTP request, '
|
||||
'but this server only speaks HTTPS on this port.'
|
||||
)
|
||||
return
|
||||
|
||||
with pytest.raises(requests.exceptions.ConnectionError) as ssl_err:
|
||||
requests.get( # FIXME: make stdlib ssl behave like PyOpenSSL
|
||||
'http://{host!s}:{port!s}/'.format(host=fqdn, port=port),
|
||||
)
|
||||
|
||||
if IS_LINUX:
|
||||
expected_error_code, expected_error_text = (
|
||||
104, 'Connection reset by peer',
|
||||
)
|
||||
if IS_MACOS:
|
||||
expected_error_code, expected_error_text = (
|
||||
54, 'Connection reset by peer',
|
||||
)
|
||||
if IS_WINDOWS:
|
||||
expected_error_code, expected_error_text = (
|
||||
10054,
|
||||
'An existing connection was forcibly closed by the remote host',
|
||||
)
|
||||
|
||||
underlying_error = ssl_err.value.args[0].args[-1]
|
||||
err_text = str(underlying_error)
|
||||
assert underlying_error.errno == expected_error_code, (
|
||||
'The underlying error is {underlying_error!r}'.
|
||||
format(**locals())
|
||||
)
|
||||
assert expected_error_text in err_text
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Test wsgi."""
|
||||
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from traceback import print_tb
|
||||
|
||||
import pytest
|
||||
import portend
|
||||
import requests
|
||||
from requests_toolbelt.sessions import BaseUrlSession as Session
|
||||
from jaraco.context import ExceptionTrap
|
||||
|
||||
from cheroot import wsgi
|
||||
from cheroot._compat import IS_MACOS, IS_WINDOWS
|
||||
|
||||
|
||||
IS_SLOW_ENV = IS_MACOS or IS_WINDOWS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_wsgi_server():
|
||||
"""Fucking simple wsgi server fixture (duh)."""
|
||||
port = portend.find_available_local_port()
|
||||
|
||||
def app(_environ, start_response):
|
||||
status = '200 OK'
|
||||
response_headers = [('Content-type', 'text/plain')]
|
||||
start_response(status, response_headers)
|
||||
return [b'Hello world!']
|
||||
|
||||
host = '::'
|
||||
addr = host, port
|
||||
server = wsgi.Server(addr, app, timeout=600 if IS_SLOW_ENV else 20)
|
||||
# pylint: disable=possibly-unused-variable
|
||||
url = 'http://localhost:{port}/'.format(**locals())
|
||||
# pylint: disable=possibly-unused-variable
|
||||
with server._run_in_thread() as thread:
|
||||
yield locals()
|
||||
|
||||
|
||||
def test_connection_keepalive(simple_wsgi_server):
|
||||
"""Test the connection keepalive works (duh)."""
|
||||
session = Session(base_url=simple_wsgi_server['url'])
|
||||
pooled = requests.adapters.HTTPAdapter(
|
||||
pool_connections=1, pool_maxsize=1000,
|
||||
)
|
||||
session.mount('http://', pooled)
|
||||
|
||||
def do_request():
|
||||
with ExceptionTrap(requests.exceptions.ConnectionError) as trap:
|
||||
resp = session.get('info')
|
||||
resp.raise_for_status()
|
||||
print_tb(trap.tb)
|
||||
return bool(trap)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10 if IS_SLOW_ENV else 50) as pool:
|
||||
tasks = [
|
||||
pool.submit(do_request)
|
||||
for n in range(250 if IS_SLOW_ENV else 1000)
|
||||
]
|
||||
failures = sum(task.result() for task in tasks)
|
||||
|
||||
assert not failures
|
||||
|
||||
|
||||
def test_gateway_start_response_called_twice(monkeypatch):
|
||||
"""Verify that repeat calls of ``Gateway.start_response()`` fail."""
|
||||
monkeypatch.setattr(wsgi.Gateway, 'get_environ', lambda self: {})
|
||||
wsgi_gateway = wsgi.Gateway(None)
|
||||
wsgi_gateway.started_response = True
|
||||
|
||||
err_msg = '^WSGI start_response called a second time with no exc_info.$'
|
||||
with pytest.raises(RuntimeError, match=err_msg):
|
||||
wsgi_gateway.start_response('200', (), None)
|
||||
|
||||
|
||||
def test_gateway_write_needs_start_response_called_before(monkeypatch):
|
||||
"""Check that calling ``Gateway.write()`` needs started response."""
|
||||
monkeypatch.setattr(wsgi.Gateway, 'get_environ', lambda self: {})
|
||||
wsgi_gateway = wsgi.Gateway(None)
|
||||
|
||||
err_msg = '^WSGI write called before start_response.$'
|
||||
with pytest.raises(RuntimeError, match=err_msg):
|
||||
wsgi_gateway.write(None) # The actual arg value is unimportant
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Extensions to unittest for web frameworks.
|
||||
|
||||
Use the :py:meth:`WebCase.getPage` method to request a page
|
||||
from your HTTP server.
|
||||
|
||||
Framework Integration
|
||||
=====================
|
||||
If you have control over your server process, you can handle errors
|
||||
in the server-side of the HTTP conversation a bit better. You must run
|
||||
both the client (your :py:class:`WebCase` tests) and the server in the
|
||||
same process (but in separate threads, obviously).
|
||||
When an error occurs in the framework, call server_error. It will print
|
||||
the traceback to stdout, and keep any assertions you have from running
|
||||
(the assumption is that, if the server errors, the page output will not
|
||||
be of further significance to your tests).
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import pprint
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import os
|
||||
import json
|
||||
import unittest # pylint: disable=deprecated-module,preferred-module
|
||||
import warnings
|
||||
import functools
|
||||
|
||||
from six.moves import http_client, map, urllib_parse
|
||||
import six
|
||||
|
||||
from more_itertools.more import always_iterable
|
||||
import jaraco.functools
|
||||
|
||||
|
||||
def interface(host):
|
||||
"""Return an IP address for a client connection given the server host.
|
||||
|
||||
If the server is listening on '0.0.0.0' (INADDR_ANY)
|
||||
or '::' (IN6ADDR_ANY), this will return the proper localhost.
|
||||
"""
|
||||
if host == '0.0.0.0':
|
||||
# INADDR_ANY, which should respond on localhost.
|
||||
return '127.0.0.1'
|
||||
if host == '::':
|
||||
# IN6ADDR_ANY, which should respond on localhost.
|
||||
return '::1'
|
||||
return host
|
||||
|
||||
|
||||
try:
|
||||
# Jython support
|
||||
if sys.platform[:4] == 'java':
|
||||
def getchar():
|
||||
"""Get a key press."""
|
||||
# Hopefully this is enough
|
||||
return sys.stdin.read(1)
|
||||
else:
|
||||
# On Windows, msvcrt.getch reads a single char without output.
|
||||
import msvcrt
|
||||
|
||||
def getchar():
|
||||
"""Get a key press."""
|
||||
return msvcrt.getch()
|
||||
except ImportError:
|
||||
# Unix getchr
|
||||
import tty
|
||||
import termios
|
||||
|
||||
def getchar():
|
||||
"""Get a key press."""
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(sys.stdin.fileno())
|
||||
ch = sys.stdin.read(1)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
return ch
|
||||
|
||||
|
||||
# from jaraco.properties
|
||||
class NonDataProperty:
|
||||
"""Non-data property decorator."""
|
||||
|
||||
def __init__(self, fget):
|
||||
"""Initialize a non-data property."""
|
||||
assert fget is not None, 'fget cannot be none'
|
||||
assert callable(fget), 'fget must be callable'
|
||||
self.fget = fget
|
||||
|
||||
def __get__(self, obj, objtype=None):
|
||||
"""Return a class property."""
|
||||
if obj is None:
|
||||
return self
|
||||
return self.fget(obj)
|
||||
|
||||
|
||||
class WebCase(unittest.TestCase):
|
||||
"""Helper web test suite base."""
|
||||
|
||||
HOST = '127.0.0.1'
|
||||
PORT = 8000
|
||||
HTTP_CONN = http_client.HTTPConnection
|
||||
PROTOCOL = 'HTTP/1.1'
|
||||
|
||||
scheme = 'http'
|
||||
url = None
|
||||
ssl_context = None
|
||||
|
||||
status = None
|
||||
headers = None
|
||||
body = None
|
||||
|
||||
encoding = 'utf-8'
|
||||
|
||||
time = None
|
||||
|
||||
@property
|
||||
def _Conn(self):
|
||||
"""Return HTTPConnection or HTTPSConnection based on self.scheme.
|
||||
|
||||
* from :py:mod:`python:http.client`.
|
||||
"""
|
||||
cls_name = '{scheme}Connection'.format(scheme=self.scheme.upper())
|
||||
return getattr(http_client, cls_name)
|
||||
|
||||
def get_conn(self, auto_open=False):
|
||||
"""Return a connection to our HTTP server."""
|
||||
conn = self._Conn(self.interface(), self.PORT)
|
||||
# Automatically re-connect?
|
||||
conn.auto_open = auto_open
|
||||
conn.connect()
|
||||
return conn
|
||||
|
||||
def set_persistent(self, on=True, auto_open=False):
|
||||
"""Make our HTTP_CONN persistent (or not).
|
||||
|
||||
If the 'on' argument is True (the default), then self.HTTP_CONN
|
||||
will be set to an instance of HTTP(S)?Connection
|
||||
to persist across requests.
|
||||
As this class only allows for a single open connection, if
|
||||
self already has an open connection, it will be closed.
|
||||
"""
|
||||
try:
|
||||
self.HTTP_CONN.close()
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
self.HTTP_CONN = (
|
||||
self.get_conn(auto_open=auto_open)
|
||||
if on
|
||||
else self._Conn
|
||||
)
|
||||
|
||||
@property
|
||||
def persistent(self):
|
||||
"""Presence of the persistent HTTP connection."""
|
||||
return hasattr(self.HTTP_CONN, '__class__')
|
||||
|
||||
@persistent.setter
|
||||
def persistent(self, on):
|
||||
self.set_persistent(on)
|
||||
|
||||
def interface(self):
|
||||
"""Return an IP address for a client connection.
|
||||
|
||||
If the server is listening on '0.0.0.0' (INADDR_ANY)
|
||||
or '::' (IN6ADDR_ANY), this will return the proper localhost.
|
||||
"""
|
||||
return interface(self.HOST)
|
||||
|
||||
def getPage(
|
||||
self, url, headers=None, method='GET', body=None,
|
||||
protocol=None, raise_subcls=(),
|
||||
):
|
||||
"""Open the url with debugging support.
|
||||
|
||||
Return status, headers, body.
|
||||
|
||||
url should be the identifier passed to the server, typically a
|
||||
server-absolute path and query string (sent between method and
|
||||
protocol), and should only be an absolute URI if proxy support is
|
||||
enabled in the server.
|
||||
|
||||
If the application under test generates absolute URIs, be sure
|
||||
to wrap them first with :py:func:`strip_netloc`::
|
||||
|
||||
>>> class MyAppWebCase(WebCase):
|
||||
... def getPage(url, *args, **kwargs):
|
||||
... super(MyAppWebCase, self).getPage(
|
||||
... cheroot.test.webtest.strip_netloc(url),
|
||||
... *args, **kwargs
|
||||
... )
|
||||
|
||||
``raise_subcls`` is passed through to :py:func:`openURL`.
|
||||
"""
|
||||
ServerError.on = False
|
||||
|
||||
if isinstance(url, six.text_type):
|
||||
url = url.encode('utf-8')
|
||||
if isinstance(body, six.text_type):
|
||||
body = body.encode('utf-8')
|
||||
|
||||
# for compatibility, support raise_subcls is None
|
||||
raise_subcls = raise_subcls or ()
|
||||
|
||||
self.url = url
|
||||
self.time = None
|
||||
start = time.time()
|
||||
result = openURL(
|
||||
url, headers, method, body, self.HOST, self.PORT,
|
||||
self.HTTP_CONN, protocol or self.PROTOCOL,
|
||||
raise_subcls=raise_subcls,
|
||||
ssl_context=self.ssl_context,
|
||||
)
|
||||
self.time = time.time() - start
|
||||
self.status, self.headers, self.body = result
|
||||
|
||||
# Build a list of request cookies from the previous response cookies.
|
||||
self.cookies = [
|
||||
('Cookie', v) for k, v in self.headers
|
||||
if k.lower() == 'set-cookie'
|
||||
]
|
||||
|
||||
if ServerError.on:
|
||||
raise ServerError()
|
||||
return result
|
||||
|
||||
@NonDataProperty
|
||||
def interactive(self):
|
||||
"""Determine whether tests are run in interactive mode.
|
||||
|
||||
Load interactivity setting from environment, where
|
||||
the value can be numeric or a string like true or
|
||||
False or 1 or 0.
|
||||
"""
|
||||
env_str = os.environ.get('WEBTEST_INTERACTIVE', 'True')
|
||||
is_interactive = bool(json.loads(env_str.lower()))
|
||||
if is_interactive:
|
||||
warnings.warn(
|
||||
'Interactive test failure interceptor support via '
|
||||
'WEBTEST_INTERACTIVE environment variable is deprecated.',
|
||||
DeprecationWarning,
|
||||
)
|
||||
return is_interactive
|
||||
|
||||
console_height = 30
|
||||
|
||||
def _handlewebError(self, msg): # noqa: C901 # FIXME
|
||||
print('')
|
||||
print(' ERROR: %s' % msg)
|
||||
|
||||
if not self.interactive:
|
||||
raise self.failureException(msg)
|
||||
|
||||
p = (
|
||||
' Show: '
|
||||
'[B]ody [H]eaders [S]tatus [U]RL; '
|
||||
'[I]gnore, [R]aise, or sys.e[X]it >> '
|
||||
)
|
||||
sys.stdout.write(p)
|
||||
sys.stdout.flush()
|
||||
while True:
|
||||
i = getchar().upper()
|
||||
if not isinstance(i, type('')):
|
||||
i = i.decode('ascii')
|
||||
if i not in 'BHSUIRX':
|
||||
continue
|
||||
print(i.upper()) # Also prints new line
|
||||
if i == 'B':
|
||||
for x, line in enumerate(self.body.splitlines()):
|
||||
if (x + 1) % self.console_height == 0:
|
||||
# The \r and comma should make the next line overwrite
|
||||
sys.stdout.write('<-- More -->\r')
|
||||
m = getchar().lower()
|
||||
# Erase our "More" prompt
|
||||
sys.stdout.write(' \r')
|
||||
if m == 'q':
|
||||
break
|
||||
print(line)
|
||||
elif i == 'H':
|
||||
pprint.pprint(self.headers)
|
||||
elif i == 'S':
|
||||
print(self.status)
|
||||
elif i == 'U':
|
||||
print(self.url)
|
||||
elif i == 'I':
|
||||
# return without raising the normal exception
|
||||
return
|
||||
elif i == 'R':
|
||||
raise self.failureException(msg)
|
||||
elif i == 'X':
|
||||
sys.exit()
|
||||
sys.stdout.write(p)
|
||||
sys.stdout.flush()
|
||||
|
||||
@property
|
||||
def status_code(self): # noqa: D401; irrelevant for properties
|
||||
"""Integer HTTP status code."""
|
||||
return int(self.status[:3])
|
||||
|
||||
def status_matches(self, expected):
|
||||
"""Check whether actual status matches expected."""
|
||||
actual = (
|
||||
self.status_code
|
||||
if isinstance(expected, int) else
|
||||
self.status
|
||||
)
|
||||
return expected == actual
|
||||
|
||||
def assertStatus(self, status, msg=None):
|
||||
"""Fail if self.status != status.
|
||||
|
||||
status may be integer code, exact string status, or
|
||||
iterable of allowed possibilities.
|
||||
"""
|
||||
if any(map(self.status_matches, always_iterable(status))):
|
||||
return
|
||||
|
||||
tmpl = 'Status {self.status} does not match {status}'
|
||||
msg = msg or tmpl.format(**locals())
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertHeader(self, key, value=None, msg=None):
|
||||
"""Fail if (key, [value]) not in self.headers."""
|
||||
lowkey = key.lower()
|
||||
for k, v in self.headers:
|
||||
if k.lower() == lowkey:
|
||||
if value is None or str(value) == v:
|
||||
return v
|
||||
|
||||
if msg is None:
|
||||
if value is None:
|
||||
msg = '%r not in headers' % key
|
||||
else:
|
||||
msg = '%r:%r not in headers' % (key, value)
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertHeaderIn(self, key, values, msg=None):
|
||||
"""Fail if header indicated by key doesn't have one of the values."""
|
||||
lowkey = key.lower()
|
||||
for k, v in self.headers:
|
||||
if k.lower() == lowkey:
|
||||
matches = [value for value in values if str(value) == v]
|
||||
if matches:
|
||||
return matches
|
||||
|
||||
if msg is None:
|
||||
msg = '%(key)r not in %(values)r' % vars()
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertHeaderItemValue(self, key, value, msg=None):
|
||||
"""Fail if the header does not contain the specified value."""
|
||||
actual_value = self.assertHeader(key, msg=msg)
|
||||
header_values = map(str.strip, actual_value.split(','))
|
||||
if value in header_values:
|
||||
return value
|
||||
|
||||
if msg is None:
|
||||
msg = '%r not in %r' % (value, header_values)
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertNoHeader(self, key, msg=None):
|
||||
"""Fail if key in self.headers."""
|
||||
lowkey = key.lower()
|
||||
matches = [k for k, v in self.headers if k.lower() == lowkey]
|
||||
if matches:
|
||||
if msg is None:
|
||||
msg = '%r in headers' % key
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertNoHeaderItemValue(self, key, value, msg=None):
|
||||
"""Fail if the header contains the specified value."""
|
||||
lowkey = key.lower()
|
||||
hdrs = self.headers
|
||||
matches = [k for k, v in hdrs if k.lower() == lowkey and v == value]
|
||||
if matches:
|
||||
if msg is None:
|
||||
msg = '%r:%r in %r' % (key, value, hdrs)
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertBody(self, value, msg=None):
|
||||
"""Fail if value != self.body."""
|
||||
if isinstance(value, six.text_type):
|
||||
value = value.encode(self.encoding)
|
||||
if value != self.body:
|
||||
if msg is None:
|
||||
msg = 'expected body:\n%r\n\nactual body:\n%r' % (
|
||||
value, self.body,
|
||||
)
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertInBody(self, value, msg=None):
|
||||
"""Fail if value not in self.body."""
|
||||
if isinstance(value, six.text_type):
|
||||
value = value.encode(self.encoding)
|
||||
if value not in self.body:
|
||||
if msg is None:
|
||||
msg = '%r not in body: %s' % (value, self.body)
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertNotInBody(self, value, msg=None):
|
||||
"""Fail if value in self.body."""
|
||||
if isinstance(value, six.text_type):
|
||||
value = value.encode(self.encoding)
|
||||
if value in self.body:
|
||||
if msg is None:
|
||||
msg = '%r found in body' % value
|
||||
self._handlewebError(msg)
|
||||
|
||||
def assertMatchesBody(self, pattern, msg=None, flags=0):
|
||||
"""Fail if value (a regex pattern) is not in self.body."""
|
||||
if isinstance(pattern, six.text_type):
|
||||
pattern = pattern.encode(self.encoding)
|
||||
if re.search(pattern, self.body, flags) is None:
|
||||
if msg is None:
|
||||
msg = 'No match for %r in body' % pattern
|
||||
self._handlewebError(msg)
|
||||
|
||||
|
||||
methods_with_bodies = ('POST', 'PUT', 'PATCH')
|
||||
|
||||
|
||||
def cleanHeaders(headers, method, body, host, port):
|
||||
"""Return request headers, with required headers added (if missing)."""
|
||||
if headers is None:
|
||||
headers = []
|
||||
|
||||
# Add the required Host request header if not present.
|
||||
# [This specifies the host:port of the server, not the client.]
|
||||
found = False
|
||||
for k, _v in headers:
|
||||
if k.lower() == 'host':
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
if port == 80:
|
||||
headers.append(('Host', host))
|
||||
else:
|
||||
headers.append(('Host', '%s:%s' % (host, port)))
|
||||
|
||||
if method in methods_with_bodies:
|
||||
# Stick in default type and length headers if not present
|
||||
found = False
|
||||
for k, v in headers:
|
||||
if k.lower() == 'content-type':
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
headers.append(
|
||||
('Content-Type', 'application/x-www-form-urlencoded'),
|
||||
)
|
||||
headers.append(('Content-Length', str(len(body or ''))))
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def shb(response):
|
||||
"""Return status, headers, body the way we like from a response."""
|
||||
resp_status_line = '%s %s' % (response.status, response.reason)
|
||||
|
||||
if not six.PY2:
|
||||
return resp_status_line, response.getheaders(), response.read()
|
||||
|
||||
h = []
|
||||
key, value = None, None
|
||||
for line in response.msg.headers:
|
||||
if line:
|
||||
if line[0] in ' \t':
|
||||
value += line.strip()
|
||||
else:
|
||||
if key and value:
|
||||
h.append((key, value))
|
||||
key, value = line.split(':', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
h.append((key, value))
|
||||
|
||||
return resp_status_line, h, response.read()
|
||||
|
||||
|
||||
# def openURL(*args, raise_subcls=(), **kwargs):
|
||||
# py27 compatible signature:
|
||||
def openURL(*args, **kwargs):
|
||||
"""
|
||||
Open a URL, retrying when it fails.
|
||||
|
||||
Specify ``raise_subcls`` (class or tuple of classes) to exclude
|
||||
those socket.error subclasses from being suppressed and retried.
|
||||
"""
|
||||
raise_subcls = kwargs.pop('raise_subcls', ())
|
||||
opener = functools.partial(_open_url_once, *args, **kwargs)
|
||||
|
||||
def on_exception():
|
||||
exc = sys.exc_info()[1]
|
||||
if isinstance(exc, raise_subcls):
|
||||
raise exc
|
||||
time.sleep(0.5)
|
||||
|
||||
# Try up to 10 times
|
||||
return jaraco.functools.retry_call(
|
||||
opener,
|
||||
retries=9,
|
||||
cleanup=on_exception,
|
||||
trap=socket.error,
|
||||
)
|
||||
|
||||
|
||||
def _open_url_once(
|
||||
url, headers=None, method='GET', body=None,
|
||||
host='127.0.0.1', port=8000, http_conn=http_client.HTTPConnection,
|
||||
protocol='HTTP/1.1', ssl_context=None,
|
||||
):
|
||||
"""Open the given HTTP resource and return status, headers, and body."""
|
||||
headers = cleanHeaders(headers, method, body, host, port)
|
||||
|
||||
# Allow http_conn to be a class or an instance
|
||||
if hasattr(http_conn, 'host'):
|
||||
conn = http_conn
|
||||
else:
|
||||
kw = {}
|
||||
if ssl_context:
|
||||
kw['context'] = ssl_context
|
||||
conn = http_conn(interface(host), port, **kw)
|
||||
conn._http_vsn_str = protocol
|
||||
conn._http_vsn = int(''.join([x for x in protocol if x.isdigit()]))
|
||||
if not six.PY2 and isinstance(url, bytes):
|
||||
url = url.decode()
|
||||
conn.putrequest(
|
||||
method.upper(), url, skip_host=True,
|
||||
skip_accept_encoding=True,
|
||||
)
|
||||
for key, value in headers:
|
||||
conn.putheader(key, value.encode('Latin-1'))
|
||||
conn.endheaders()
|
||||
if body is not None:
|
||||
conn.send(body)
|
||||
# Handle response
|
||||
response = conn.getresponse()
|
||||
s, h, b = shb(response)
|
||||
if not hasattr(http_conn, 'host'):
|
||||
# We made our own conn instance. Close it.
|
||||
conn.close()
|
||||
return s, h, b
|
||||
|
||||
|
||||
def strip_netloc(url):
|
||||
"""Return absolute-URI path from URL.
|
||||
|
||||
Strip the scheme and host from the URL, returning the
|
||||
server-absolute portion.
|
||||
|
||||
Useful for wrapping an absolute-URI for which only the
|
||||
path is expected (such as in calls to :py:meth:`WebCase.getPage`).
|
||||
|
||||
.. testsetup::
|
||||
|
||||
from cheroot.test.webtest import strip_netloc
|
||||
|
||||
>>> strip_netloc('https://google.com/foo/bar?bing#baz')
|
||||
'/foo/bar?bing'
|
||||
|
||||
>>> strip_netloc('//google.com/foo/bar?bing#baz')
|
||||
'/foo/bar?bing'
|
||||
|
||||
>>> strip_netloc('/foo/bar?bing#baz')
|
||||
'/foo/bar?bing'
|
||||
"""
|
||||
parsed = urllib_parse.urlparse(url)
|
||||
_scheme, _netloc, path, params, query, _fragment = parsed
|
||||
stripped = '', '', path, params, query, ''
|
||||
return urllib_parse.urlunparse(stripped)
|
||||
|
||||
|
||||
# Add any exceptions which your web framework handles
|
||||
# normally (that you don't want server_error to trap).
|
||||
ignored_exceptions = []
|
||||
|
||||
# You'll want set this to True when you can't guarantee
|
||||
# that each response will immediately follow each request;
|
||||
# for example, when handling requests via multiple threads.
|
||||
ignore_all = False
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Exception for signalling server error."""
|
||||
|
||||
on = False
|
||||
|
||||
|
||||
def server_error(exc=None):
|
||||
"""Server debug hook.
|
||||
|
||||
Return True if exception handled, False if ignored.
|
||||
You probably want to wrap this, so you can still handle an error using
|
||||
your framework when it's ignored.
|
||||
"""
|
||||
if exc is None:
|
||||
exc = sys.exc_info()
|
||||
|
||||
if ignore_all or exc[0] in ignored_exceptions:
|
||||
return False
|
||||
else:
|
||||
ServerError.on = True
|
||||
print('')
|
||||
print(''.join(traceback.format_exception(*exc)))
|
||||
return True
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Pytest fixtures and other helpers for doing testing by end-users."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
from contextlib import closing
|
||||
import errno
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from six.moves import http_client
|
||||
|
||||
import cheroot.server
|
||||
from cheroot.test import webtest
|
||||
import cheroot.wsgi
|
||||
|
||||
EPHEMERAL_PORT = 0
|
||||
NO_INTERFACE = None # Using this or '' will cause an exception
|
||||
ANY_INTERFACE_IPV4 = '0.0.0.0'
|
||||
ANY_INTERFACE_IPV6 = '::'
|
||||
|
||||
config = {
|
||||
cheroot.wsgi.Server: {
|
||||
'bind_addr': (NO_INTERFACE, EPHEMERAL_PORT),
|
||||
'wsgi_app': None,
|
||||
},
|
||||
cheroot.server.HTTPServer: {
|
||||
'bind_addr': (NO_INTERFACE, EPHEMERAL_PORT),
|
||||
'gateway': cheroot.server.Gateway,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def cheroot_server(server_factory):
|
||||
"""Set up and tear down a Cheroot server instance."""
|
||||
conf = config[server_factory].copy()
|
||||
bind_port = conf.pop('bind_addr')[-1]
|
||||
|
||||
for interface in ANY_INTERFACE_IPV6, ANY_INTERFACE_IPV4:
|
||||
try:
|
||||
actual_bind_addr = (interface, bind_port)
|
||||
httpserver = server_factory( # create it
|
||||
bind_addr=actual_bind_addr,
|
||||
**conf
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
|
||||
httpserver.shutdown_timeout = 0 # Speed-up tests teardown
|
||||
|
||||
threading.Thread(target=httpserver.safe_start).start() # spawn it
|
||||
while not httpserver.ready: # wait until fully initialized and bound
|
||||
time.sleep(0.1)
|
||||
|
||||
yield httpserver
|
||||
|
||||
httpserver.stop() # destroy it
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wsgi_server():
|
||||
"""Set up and tear down a Cheroot WSGI server instance."""
|
||||
for srv in cheroot_server(cheroot.wsgi.Server):
|
||||
yield srv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def native_server():
|
||||
"""Set up and tear down a Cheroot HTTP server instance."""
|
||||
for srv in cheroot_server(cheroot.server.HTTPServer):
|
||||
yield srv
|
||||
|
||||
|
||||
class _TestClient:
|
||||
def __init__(self, server):
|
||||
self._interface, self._host, self._port = _get_conn_data(
|
||||
server.bind_addr,
|
||||
)
|
||||
self.server_instance = server
|
||||
self._http_connection = self.get_connection()
|
||||
|
||||
def get_connection(self):
|
||||
name = '{interface}:{port}'.format(
|
||||
interface=self._interface,
|
||||
port=self._port,
|
||||
)
|
||||
conn_cls = (
|
||||
http_client.HTTPConnection
|
||||
if self.server_instance.ssl_adapter is None else
|
||||
http_client.HTTPSConnection
|
||||
)
|
||||
return conn_cls(name)
|
||||
|
||||
def request(
|
||||
self, uri, method='GET', headers=None, http_conn=None,
|
||||
protocol='HTTP/1.1',
|
||||
):
|
||||
return webtest.openURL(
|
||||
uri, method=method,
|
||||
headers=headers,
|
||||
host=self._host, port=self._port,
|
||||
http_conn=http_conn or self._http_connection,
|
||||
protocol=protocol,
|
||||
)
|
||||
|
||||
def __getattr__(self, attr_name):
|
||||
def _wrapper(uri, **kwargs):
|
||||
http_method = attr_name.upper()
|
||||
return self.request(uri, method=http_method, **kwargs)
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def _probe_ipv6_sock(interface):
|
||||
# Alternate way is to check IPs on interfaces using glibc, like:
|
||||
# github.com/Gautier/minifail/blob/master/minifail/getifaddrs.py
|
||||
try:
|
||||
with closing(socket.socket(family=socket.AF_INET6)) as sock:
|
||||
sock.bind((interface, 0))
|
||||
except (OSError, socket.error) as sock_err:
|
||||
# In Python 3 socket.error is an alias for OSError
|
||||
# In Python 2 socket.error is a subclass of IOError
|
||||
if sock_err.errno != errno.EADDRNOTAVAIL:
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _get_conn_data(bind_addr):
|
||||
if isinstance(bind_addr, tuple):
|
||||
host, port = bind_addr
|
||||
else:
|
||||
host, port = bind_addr, 0
|
||||
|
||||
interface = webtest.interface(host)
|
||||
|
||||
if ':' in interface and not _probe_ipv6_sock(interface):
|
||||
interface = '127.0.0.1'
|
||||
if ':' in host:
|
||||
host = interface
|
||||
|
||||
return interface, host, port
|
||||
|
||||
|
||||
def get_server_client(server):
|
||||
"""Create and return a test client for the given server."""
|
||||
return _TestClient(server)
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Any, Iterator, Optional, TypeVar
|
||||
|
||||
from .server import HTTPServer
|
||||
from .wsgi import Server
|
||||
|
||||
T = TypeVar('T', bound=HTTPServer)
|
||||
|
||||
EPHEMERAL_PORT: int
|
||||
NO_INTERFACE: Optional[str]
|
||||
ANY_INTERFACE_IPV4: str
|
||||
ANY_INTERFACE_IPV6: str
|
||||
config: dict
|
||||
|
||||
def cheroot_server(server_factory: T) -> Iterator[T]: ...
|
||||
def wsgi_server() -> Iterator[Server]: ...
|
||||
def native_server() -> Iterator[HTTPServer]: ...
|
||||
def get_server_client(server) -> Any: ...
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP workers pool."""
|
||||
@@ -0,0 +1,330 @@
|
||||
"""A thread-based worker pool.
|
||||
|
||||
.. spelling::
|
||||
|
||||
joinable
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import collections
|
||||
import threading
|
||||
import time
|
||||
import socket
|
||||
import warnings
|
||||
|
||||
from six.moves import queue
|
||||
|
||||
from jaraco.functools import pass_none
|
||||
|
||||
|
||||
__all__ = ('WorkerThread', 'ThreadPool')
|
||||
|
||||
|
||||
class TrueyZero:
|
||||
"""Object which equals and does math like the integer 0 but evals True."""
|
||||
|
||||
def __add__(self, other):
|
||||
return other
|
||||
|
||||
def __radd__(self, other):
|
||||
return other
|
||||
|
||||
|
||||
trueyzero = TrueyZero()
|
||||
|
||||
_SHUTDOWNREQUEST = None
|
||||
|
||||
|
||||
class WorkerThread(threading.Thread):
|
||||
"""Thread which continuously polls a Queue for Connection objects.
|
||||
|
||||
Due to the timing issues of polling a Queue, a WorkerThread does not
|
||||
check its own 'ready' flag after it has started. To stop the thread,
|
||||
it is necessary to stick a _SHUTDOWNREQUEST object onto the Queue
|
||||
(one for each running WorkerThread).
|
||||
"""
|
||||
|
||||
conn = None
|
||||
"""The current connection pulled off the Queue, or None."""
|
||||
|
||||
server = None
|
||||
"""The HTTP Server which spawned this thread, and which owns the
|
||||
Queue and is placing active connections into it."""
|
||||
|
||||
ready = False
|
||||
"""A simple flag for the calling server to know when this thread
|
||||
has begun polling the Queue."""
|
||||
|
||||
def __init__(self, server):
|
||||
"""Initialize WorkerThread instance.
|
||||
|
||||
Args:
|
||||
server (cheroot.server.HTTPServer): web server object
|
||||
receiving this request
|
||||
"""
|
||||
self.ready = False
|
||||
self.server = server
|
||||
|
||||
self.requests_seen = 0
|
||||
self.bytes_read = 0
|
||||
self.bytes_written = 0
|
||||
self.start_time = None
|
||||
self.work_time = 0
|
||||
self.stats = {
|
||||
'Requests': lambda s: self.requests_seen + (
|
||||
self.start_time is None
|
||||
and trueyzero
|
||||
or self.conn.requests_seen
|
||||
),
|
||||
'Bytes Read': lambda s: self.bytes_read + (
|
||||
self.start_time is None
|
||||
and trueyzero
|
||||
or self.conn.rfile.bytes_read
|
||||
),
|
||||
'Bytes Written': lambda s: self.bytes_written + (
|
||||
self.start_time is None
|
||||
and trueyzero
|
||||
or self.conn.wfile.bytes_written
|
||||
),
|
||||
'Work Time': lambda s: self.work_time + (
|
||||
self.start_time is None
|
||||
and trueyzero
|
||||
or time.time() - self.start_time
|
||||
),
|
||||
'Read Throughput': lambda s: s['Bytes Read'](s) / (
|
||||
s['Work Time'](s) or 1e-6
|
||||
),
|
||||
'Write Throughput': lambda s: s['Bytes Written'](s) / (
|
||||
s['Work Time'](s) or 1e-6
|
||||
),
|
||||
}
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
def run(self):
|
||||
"""Process incoming HTTP connections.
|
||||
|
||||
Retrieves incoming connections from thread pool.
|
||||
"""
|
||||
self.server.stats['Worker Threads'][self.name] = self.stats
|
||||
try:
|
||||
self.ready = True
|
||||
while True:
|
||||
conn = self.server.requests.get()
|
||||
if conn is _SHUTDOWNREQUEST:
|
||||
return
|
||||
|
||||
self.conn = conn
|
||||
is_stats_enabled = self.server.stats['Enabled']
|
||||
if is_stats_enabled:
|
||||
self.start_time = time.time()
|
||||
keep_conn_open = False
|
||||
try:
|
||||
keep_conn_open = conn.communicate()
|
||||
finally:
|
||||
if keep_conn_open:
|
||||
self.server.put_conn(conn)
|
||||
else:
|
||||
conn.close()
|
||||
if is_stats_enabled:
|
||||
self.requests_seen += self.conn.requests_seen
|
||||
self.bytes_read += self.conn.rfile.bytes_read
|
||||
self.bytes_written += self.conn.wfile.bytes_written
|
||||
self.work_time += time.time() - self.start_time
|
||||
self.start_time = None
|
||||
self.conn = None
|
||||
except (KeyboardInterrupt, SystemExit) as ex:
|
||||
self.server.interrupt = ex
|
||||
|
||||
|
||||
class ThreadPool:
|
||||
"""A Request Queue for an HTTPServer which pools threads.
|
||||
|
||||
ThreadPool objects must provide min, get(), put(obj), start()
|
||||
and stop(timeout) attributes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, server, min=10, max=-1, accepted_queue_size=-1,
|
||||
accepted_queue_timeout=10,
|
||||
):
|
||||
"""Initialize HTTP requests queue instance.
|
||||
|
||||
Args:
|
||||
server (cheroot.server.HTTPServer): web server object
|
||||
receiving this request
|
||||
min (int): minimum number of worker threads
|
||||
max (int): maximum number of worker threads
|
||||
accepted_queue_size (int): maximum number of active
|
||||
requests in queue
|
||||
accepted_queue_timeout (int): timeout for putting request
|
||||
into queue
|
||||
"""
|
||||
self.server = server
|
||||
self.min = min
|
||||
self.max = max
|
||||
self._threads = []
|
||||
self._queue = queue.Queue(maxsize=accepted_queue_size)
|
||||
self._queue_put_timeout = accepted_queue_timeout
|
||||
self.get = self._queue.get
|
||||
self._pending_shutdowns = collections.deque()
|
||||
|
||||
def start(self):
|
||||
"""Start the pool of threads."""
|
||||
for _ in range(self.min):
|
||||
self._threads.append(WorkerThread(self.server))
|
||||
for worker in self._threads:
|
||||
worker.name = (
|
||||
'CP Server {worker_name!s}'.
|
||||
format(worker_name=worker.name),
|
||||
)
|
||||
worker.start()
|
||||
for worker in self._threads:
|
||||
while not worker.ready:
|
||||
time.sleep(.1)
|
||||
|
||||
@property
|
||||
def idle(self): # noqa: D401; irrelevant for properties
|
||||
"""Number of worker threads which are idle. Read-only.""" # noqa: D401
|
||||
idles = len([t for t in self._threads if t.conn is None])
|
||||
return max(idles - len(self._pending_shutdowns), 0)
|
||||
|
||||
def put(self, obj):
|
||||
"""Put request into queue.
|
||||
|
||||
Args:
|
||||
obj (:py:class:`~cheroot.server.HTTPConnection`): HTTP connection
|
||||
waiting to be processed
|
||||
"""
|
||||
self._queue.put(obj, block=True, timeout=self._queue_put_timeout)
|
||||
|
||||
def _clear_dead_threads(self):
|
||||
# Remove any dead threads from our list
|
||||
for t in [t for t in self._threads if not t.is_alive()]:
|
||||
self._threads.remove(t)
|
||||
try:
|
||||
self._pending_shutdowns.popleft()
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def grow(self, amount):
|
||||
"""Spawn new worker threads (not above self.max)."""
|
||||
if self.max > 0:
|
||||
budget = max(self.max - len(self._threads), 0)
|
||||
else:
|
||||
# self.max <= 0 indicates no maximum
|
||||
budget = float('inf')
|
||||
|
||||
n_new = min(amount, budget)
|
||||
|
||||
workers = [self._spawn_worker() for i in range(n_new)]
|
||||
while not all(worker.ready for worker in workers):
|
||||
time.sleep(.1)
|
||||
self._threads.extend(workers)
|
||||
|
||||
def _spawn_worker(self):
|
||||
worker = WorkerThread(self.server)
|
||||
worker.name = (
|
||||
'CP Server {worker_name!s}'.
|
||||
format(worker_name=worker.name),
|
||||
)
|
||||
worker.start()
|
||||
return worker
|
||||
|
||||
def shrink(self, amount):
|
||||
"""Kill off worker threads (not below self.min)."""
|
||||
# Grow/shrink the pool if necessary.
|
||||
# Remove any dead threads from our list
|
||||
amount -= len(self._pending_shutdowns)
|
||||
self._clear_dead_threads()
|
||||
if amount <= 0:
|
||||
return
|
||||
|
||||
# calculate the number of threads above the minimum
|
||||
n_extra = max(len(self._threads) - self.min, 0)
|
||||
|
||||
# don't remove more than amount
|
||||
n_to_remove = min(amount, n_extra)
|
||||
|
||||
# put shutdown requests on the queue equal to the number of threads
|
||||
# to remove. As each request is processed by a worker, that worker
|
||||
# will terminate and be culled from the list.
|
||||
for _ in range(n_to_remove):
|
||||
self._pending_shutdowns.append(None)
|
||||
self._queue.put(_SHUTDOWNREQUEST)
|
||||
|
||||
def stop(self, timeout=5):
|
||||
"""Terminate all worker threads.
|
||||
|
||||
Args:
|
||||
timeout (int): time to wait for threads to stop gracefully
|
||||
"""
|
||||
# for compatability, negative timeouts are treated like None
|
||||
# TODO: treat negative timeouts like already expired timeouts
|
||||
if timeout is not None and timeout < 0:
|
||||
timeout = None
|
||||
warnings.warning(
|
||||
'In the future, negative timeouts to Server.stop() '
|
||||
'will be equivalent to a timeout of zero.',
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if timeout is not None:
|
||||
endtime = time.time() + timeout
|
||||
|
||||
# Must shut down threads here so the code that calls
|
||||
# this method can know when all threads are stopped.
|
||||
for worker in self._threads:
|
||||
self._queue.put(_SHUTDOWNREQUEST)
|
||||
|
||||
ignored_errors = (
|
||||
# Raised when start_response called >1 time w/o exc_info or
|
||||
# wsgi write is called before start_response. See cheroot#261
|
||||
RuntimeError,
|
||||
# Ignore repeated Ctrl-C. See cherrypy#691.
|
||||
KeyboardInterrupt,
|
||||
)
|
||||
|
||||
for worker in self._clear_threads():
|
||||
remaining_time = timeout and endtime - time.time()
|
||||
try:
|
||||
worker.join(remaining_time)
|
||||
if worker.is_alive():
|
||||
# Timeout exhausted; forcibly shut down the socket.
|
||||
self._force_close(worker.conn)
|
||||
worker.join()
|
||||
except ignored_errors:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@pass_none
|
||||
def _force_close(conn):
|
||||
if conn.rfile.closed:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
conn.socket.shutdown(socket.SHUT_RD)
|
||||
except TypeError:
|
||||
# pyOpenSSL sockets don't take an arg
|
||||
conn.socket.shutdown()
|
||||
except OSError:
|
||||
# shutdown sometimes fails (race with 'closed' check?)
|
||||
# ref #238
|
||||
pass
|
||||
|
||||
def _clear_threads(self):
|
||||
"""Clear self._threads and yield all joinable threads."""
|
||||
# threads = pop_all(self._threads)
|
||||
threads, self._threads[:] = self._threads[:], []
|
||||
return (
|
||||
thread
|
||||
for thread in threads
|
||||
if thread is not threading.current_thread()
|
||||
)
|
||||
|
||||
@property
|
||||
def qsize(self):
|
||||
"""Return the queue size."""
|
||||
return self._queue.qsize()
|
||||
@@ -0,0 +1,37 @@
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
class TrueyZero:
|
||||
def __add__(self, other): ...
|
||||
def __radd__(self, other): ...
|
||||
|
||||
trueyzero: TrueyZero
|
||||
|
||||
class WorkerThread(threading.Thread):
|
||||
conn: Any
|
||||
server: Any
|
||||
ready: bool
|
||||
requests_seen: int
|
||||
bytes_read: int
|
||||
bytes_written: int
|
||||
start_time: Any
|
||||
work_time: int
|
||||
stats: Any
|
||||
def __init__(self, server): ...
|
||||
def run(self) -> None: ...
|
||||
|
||||
class ThreadPool:
|
||||
server: Any
|
||||
min: Any
|
||||
max: Any
|
||||
get: Any
|
||||
def __init__(self, server, min: int = ..., max: int = ..., accepted_queue_size: int = ..., accepted_queue_timeout: int = ...) -> None: ...
|
||||
def start(self) -> None: ...
|
||||
@property
|
||||
def idle(self): ...
|
||||
def put(self, obj) -> None: ...
|
||||
def grow(self, amount) -> None: ...
|
||||
def shrink(self, amount) -> None: ...
|
||||
def stop(self, timeout: int = ...) -> None: ...
|
||||
@property
|
||||
def qsize(self) -> int: ...
|
||||
@@ -0,0 +1,435 @@
|
||||
"""This class holds Cheroot WSGI server implementation.
|
||||
|
||||
Simplest example on how to use this server::
|
||||
|
||||
from cheroot import wsgi
|
||||
|
||||
def my_crazy_app(environ, start_response):
|
||||
status = '200 OK'
|
||||
response_headers = [('Content-type','text/plain')]
|
||||
start_response(status, response_headers)
|
||||
return [b'Hello world!']
|
||||
|
||||
addr = '0.0.0.0', 8070
|
||||
server = wsgi.Server(addr, my_crazy_app)
|
||||
server.start()
|
||||
|
||||
The Cheroot WSGI server can serve as many WSGI applications
|
||||
as you want in one instance by using a PathInfoDispatcher::
|
||||
|
||||
path_map = {
|
||||
'/': my_crazy_app,
|
||||
'/blog': my_blog_app,
|
||||
}
|
||||
d = wsgi.PathInfoDispatcher(path_map)
|
||||
server = wsgi.Server(addr, d)
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
import sys
|
||||
|
||||
import six
|
||||
from six.moves import filter
|
||||
|
||||
from . import server
|
||||
from .workers import threadpool
|
||||
from ._compat import ntob, bton
|
||||
|
||||
|
||||
class Server(server.HTTPServer):
|
||||
"""A subclass of HTTPServer which calls a WSGI application."""
|
||||
|
||||
wsgi_version = (1, 0)
|
||||
"""The version of WSGI to produce."""
|
||||
|
||||
def __init__(
|
||||
self, bind_addr, wsgi_app, numthreads=10, server_name=None,
|
||||
max=-1, request_queue_size=5, timeout=10, shutdown_timeout=5,
|
||||
accepted_queue_size=-1, accepted_queue_timeout=10,
|
||||
peercreds_enabled=False, peercreds_resolve_enabled=False,
|
||||
):
|
||||
"""Initialize WSGI Server instance.
|
||||
|
||||
Args:
|
||||
bind_addr (tuple): network interface to listen to
|
||||
wsgi_app (callable): WSGI application callable
|
||||
numthreads (int): number of threads for WSGI thread pool
|
||||
server_name (str): web server name to be advertised via
|
||||
Server HTTP header
|
||||
max (int): maximum number of worker threads
|
||||
request_queue_size (int): the 'backlog' arg to
|
||||
socket.listen(); max queued connections
|
||||
timeout (int): the timeout in seconds for accepted connections
|
||||
shutdown_timeout (int): the total time, in seconds, to
|
||||
wait for worker threads to cleanly exit
|
||||
accepted_queue_size (int): maximum number of active
|
||||
requests in queue
|
||||
accepted_queue_timeout (int): timeout for putting request
|
||||
into queue
|
||||
"""
|
||||
super(Server, self).__init__(
|
||||
bind_addr,
|
||||
gateway=wsgi_gateways[self.wsgi_version],
|
||||
server_name=server_name,
|
||||
peercreds_enabled=peercreds_enabled,
|
||||
peercreds_resolve_enabled=peercreds_resolve_enabled,
|
||||
)
|
||||
self.wsgi_app = wsgi_app
|
||||
self.request_queue_size = request_queue_size
|
||||
self.timeout = timeout
|
||||
self.shutdown_timeout = shutdown_timeout
|
||||
self.requests = threadpool.ThreadPool(
|
||||
self, min=numthreads or 1, max=max,
|
||||
accepted_queue_size=accepted_queue_size,
|
||||
accepted_queue_timeout=accepted_queue_timeout,
|
||||
)
|
||||
|
||||
@property
|
||||
def numthreads(self):
|
||||
"""Set minimum number of threads."""
|
||||
return self.requests.min
|
||||
|
||||
@numthreads.setter
|
||||
def numthreads(self, value):
|
||||
self.requests.min = value
|
||||
|
||||
|
||||
class Gateway(server.Gateway):
|
||||
"""A base class to interface HTTPServer with WSGI."""
|
||||
|
||||
def __init__(self, req):
|
||||
"""Initialize WSGI Gateway instance with request.
|
||||
|
||||
Args:
|
||||
req (HTTPRequest): current HTTP request
|
||||
"""
|
||||
super(Gateway, self).__init__(req)
|
||||
self.started_response = False
|
||||
self.env = self.get_environ()
|
||||
self.remaining_bytes_out = None
|
||||
|
||||
@classmethod
|
||||
def gateway_map(cls):
|
||||
"""Create a mapping of gateways and their versions.
|
||||
|
||||
Returns:
|
||||
dict[tuple[int,int],class]: map of gateway version and
|
||||
corresponding class
|
||||
|
||||
"""
|
||||
return {gw.version: gw for gw in cls.__subclasses__()}
|
||||
|
||||
def get_environ(self):
|
||||
"""Return a new environ dict targeting the given wsgi.version."""
|
||||
raise NotImplementedError # pragma: no cover
|
||||
|
||||
def respond(self):
|
||||
"""Process the current request.
|
||||
|
||||
From :pep:`333`:
|
||||
|
||||
The start_response callable must not actually transmit
|
||||
the response headers. Instead, it must store them for the
|
||||
server or gateway to transmit only after the first
|
||||
iteration of the application return value that yields
|
||||
a NON-EMPTY string, or upon the application's first
|
||||
invocation of the write() callable.
|
||||
"""
|
||||
response = self.req.server.wsgi_app(self.env, self.start_response)
|
||||
try:
|
||||
for chunk in filter(None, response):
|
||||
if not isinstance(chunk, six.binary_type):
|
||||
raise ValueError('WSGI Applications must yield bytes')
|
||||
self.write(chunk)
|
||||
finally:
|
||||
# Send headers if not already sent
|
||||
self.req.ensure_headers_sent()
|
||||
if hasattr(response, 'close'):
|
||||
response.close()
|
||||
|
||||
def start_response(self, status, headers, exc_info=None):
|
||||
"""WSGI callable to begin the HTTP response."""
|
||||
# "The application may call start_response more than once,
|
||||
# if and only if the exc_info argument is provided."
|
||||
if self.started_response and not exc_info:
|
||||
raise RuntimeError(
|
||||
'WSGI start_response called a second '
|
||||
'time with no exc_info.',
|
||||
)
|
||||
self.started_response = True
|
||||
|
||||
# "if exc_info is provided, and the HTTP headers have already been
|
||||
# sent, start_response must raise an error, and should raise the
|
||||
# exc_info tuple."
|
||||
if self.req.sent_headers:
|
||||
try:
|
||||
six.reraise(*exc_info)
|
||||
finally:
|
||||
exc_info = None
|
||||
|
||||
self.req.status = self._encode_status(status)
|
||||
|
||||
for k, v in headers:
|
||||
if not isinstance(k, str):
|
||||
raise TypeError(
|
||||
'WSGI response header key %r is not of type str.' % k,
|
||||
)
|
||||
if not isinstance(v, str):
|
||||
raise TypeError(
|
||||
'WSGI response header value %r is not of type str.' % v,
|
||||
)
|
||||
if k.lower() == 'content-length':
|
||||
self.remaining_bytes_out = int(v)
|
||||
out_header = ntob(k), ntob(v)
|
||||
self.req.outheaders.append(out_header)
|
||||
|
||||
return self.write
|
||||
|
||||
@staticmethod
|
||||
def _encode_status(status):
|
||||
"""Cast status to bytes representation of current Python version.
|
||||
|
||||
According to :pep:`3333`, when using Python 3, the response status
|
||||
and headers must be bytes masquerading as Unicode; that is, they
|
||||
must be of type "str" but are restricted to code points in the
|
||||
"Latin-1" set.
|
||||
"""
|
||||
if six.PY2:
|
||||
return status
|
||||
if not isinstance(status, str):
|
||||
raise TypeError('WSGI response status is not of type str.')
|
||||
return status.encode('ISO-8859-1')
|
||||
|
||||
def write(self, chunk):
|
||||
"""WSGI callable to write unbuffered data to the client.
|
||||
|
||||
This method is also used internally by start_response (to write
|
||||
data from the iterable returned by the WSGI application).
|
||||
"""
|
||||
if not self.started_response:
|
||||
raise RuntimeError('WSGI write called before start_response.')
|
||||
|
||||
chunklen = len(chunk)
|
||||
rbo = self.remaining_bytes_out
|
||||
if rbo is not None and chunklen > rbo:
|
||||
if not self.req.sent_headers:
|
||||
# Whew. We can send a 500 to the client.
|
||||
self.req.simple_response(
|
||||
'500 Internal Server Error',
|
||||
'The requested resource returned more bytes than the '
|
||||
'declared Content-Length.',
|
||||
)
|
||||
else:
|
||||
# Dang. We have probably already sent data. Truncate the chunk
|
||||
# to fit (so the client doesn't hang) and raise an error later.
|
||||
chunk = chunk[:rbo]
|
||||
|
||||
self.req.ensure_headers_sent()
|
||||
|
||||
self.req.write(chunk)
|
||||
|
||||
if rbo is not None:
|
||||
rbo -= chunklen
|
||||
if rbo < 0:
|
||||
raise ValueError(
|
||||
'Response body exceeds the declared Content-Length.',
|
||||
)
|
||||
|
||||
|
||||
class Gateway_10(Gateway):
|
||||
"""A Gateway class to interface HTTPServer with WSGI 1.0.x."""
|
||||
|
||||
version = 1, 0
|
||||
|
||||
def get_environ(self):
|
||||
"""Return a new environ dict targeting the given wsgi.version."""
|
||||
req = self.req
|
||||
req_conn = req.conn
|
||||
env = {
|
||||
# set a non-standard environ entry so the WSGI app can know what
|
||||
# the *real* server protocol is (and what features to support).
|
||||
# See http://www.faqs.org/rfcs/rfc2145.html.
|
||||
'ACTUAL_SERVER_PROTOCOL': req.server.protocol,
|
||||
'PATH_INFO': bton(req.path),
|
||||
'QUERY_STRING': bton(req.qs),
|
||||
'REMOTE_ADDR': req_conn.remote_addr or '',
|
||||
'REMOTE_PORT': str(req_conn.remote_port or ''),
|
||||
'REQUEST_METHOD': bton(req.method),
|
||||
'REQUEST_URI': bton(req.uri),
|
||||
'SCRIPT_NAME': '',
|
||||
'SERVER_NAME': req.server.server_name,
|
||||
# Bah. "SERVER_PROTOCOL" is actually the REQUEST protocol.
|
||||
'SERVER_PROTOCOL': bton(req.request_protocol),
|
||||
'SERVER_SOFTWARE': req.server.software,
|
||||
'wsgi.errors': sys.stderr,
|
||||
'wsgi.input': req.rfile,
|
||||
'wsgi.input_terminated': bool(req.chunked_read),
|
||||
'wsgi.multiprocess': False,
|
||||
'wsgi.multithread': True,
|
||||
'wsgi.run_once': False,
|
||||
'wsgi.url_scheme': bton(req.scheme),
|
||||
'wsgi.version': self.version,
|
||||
}
|
||||
|
||||
if isinstance(req.server.bind_addr, six.string_types):
|
||||
# AF_UNIX. This isn't really allowed by WSGI, which doesn't
|
||||
# address unix domain sockets. But it's better than nothing.
|
||||
env['SERVER_PORT'] = ''
|
||||
try:
|
||||
env['X_REMOTE_PID'] = str(req_conn.peer_pid)
|
||||
env['X_REMOTE_UID'] = str(req_conn.peer_uid)
|
||||
env['X_REMOTE_GID'] = str(req_conn.peer_gid)
|
||||
|
||||
env['X_REMOTE_USER'] = str(req_conn.peer_user)
|
||||
env['X_REMOTE_GROUP'] = str(req_conn.peer_group)
|
||||
|
||||
env['REMOTE_USER'] = env['X_REMOTE_USER']
|
||||
except RuntimeError:
|
||||
"""Unable to retrieve peer creds data.
|
||||
|
||||
Unsupported by current kernel or socket error happened, or
|
||||
unsupported socket type, or disabled.
|
||||
"""
|
||||
else:
|
||||
env['SERVER_PORT'] = str(req.server.bind_addr[1])
|
||||
|
||||
# Request headers
|
||||
env.update(
|
||||
(
|
||||
'HTTP_{header_name!s}'.
|
||||
format(header_name=bton(k).upper().replace('-', '_')),
|
||||
bton(v),
|
||||
)
|
||||
for k, v in req.inheaders.items()
|
||||
)
|
||||
|
||||
# CONTENT_TYPE/CONTENT_LENGTH
|
||||
ct = env.pop('HTTP_CONTENT_TYPE', None)
|
||||
if ct is not None:
|
||||
env['CONTENT_TYPE'] = ct
|
||||
cl = env.pop('HTTP_CONTENT_LENGTH', None)
|
||||
if cl is not None:
|
||||
env['CONTENT_LENGTH'] = cl
|
||||
|
||||
if req.conn.ssl_env:
|
||||
env.update(req.conn.ssl_env)
|
||||
|
||||
return env
|
||||
|
||||
|
||||
class Gateway_u0(Gateway_10):
|
||||
"""A Gateway class to interface HTTPServer with WSGI u.0.
|
||||
|
||||
WSGI u.0 is an experimental protocol, which uses Unicode for keys
|
||||
and values in both Python 2 and Python 3.
|
||||
"""
|
||||
|
||||
version = 'u', 0
|
||||
|
||||
def get_environ(self):
|
||||
"""Return a new environ dict targeting the given wsgi.version."""
|
||||
req = self.req
|
||||
env_10 = super(Gateway_u0, self).get_environ()
|
||||
env = dict(map(self._decode_key, env_10.items()))
|
||||
|
||||
# Request-URI
|
||||
enc = env.setdefault(six.u('wsgi.url_encoding'), six.u('utf-8'))
|
||||
try:
|
||||
env['PATH_INFO'] = req.path.decode(enc)
|
||||
env['QUERY_STRING'] = req.qs.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
# Fall back to latin 1 so apps can transcode if needed.
|
||||
env['wsgi.url_encoding'] = 'ISO-8859-1'
|
||||
env['PATH_INFO'] = env_10['PATH_INFO']
|
||||
env['QUERY_STRING'] = env_10['QUERY_STRING']
|
||||
|
||||
env.update(map(self._decode_value, env.items()))
|
||||
|
||||
return env
|
||||
|
||||
@staticmethod
|
||||
def _decode_key(item):
|
||||
k, v = item
|
||||
if six.PY2:
|
||||
k = k.decode('ISO-8859-1')
|
||||
return k, v
|
||||
|
||||
@staticmethod
|
||||
def _decode_value(item):
|
||||
k, v = item
|
||||
skip_keys = 'REQUEST_URI', 'wsgi.input'
|
||||
if not six.PY2 or not isinstance(v, bytes) or k in skip_keys:
|
||||
return k, v
|
||||
return k, v.decode('ISO-8859-1')
|
||||
|
||||
|
||||
wsgi_gateways = Gateway.gateway_map()
|
||||
|
||||
|
||||
class PathInfoDispatcher:
|
||||
"""A WSGI dispatcher for dispatch based on the PATH_INFO."""
|
||||
|
||||
def __init__(self, apps):
|
||||
"""Initialize path info WSGI app dispatcher.
|
||||
|
||||
Args:
|
||||
apps (dict[str,object]|list[tuple[str,object]]): URI prefix
|
||||
and WSGI app pairs
|
||||
"""
|
||||
try:
|
||||
apps = list(apps.items())
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Sort the apps by len(path), descending
|
||||
def by_path_len(app):
|
||||
return len(app[0])
|
||||
apps.sort(key=by_path_len, reverse=True)
|
||||
|
||||
# The path_prefix strings must start, but not end, with a slash.
|
||||
# Use "" instead of "/".
|
||||
self.apps = [(p.rstrip('/'), a) for p, a in apps]
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
"""Process incoming WSGI request.
|
||||
|
||||
Ref: :pep:`3333`
|
||||
|
||||
Args:
|
||||
environ (Mapping): a dict containing WSGI environment variables
|
||||
start_response (callable): function, which sets response
|
||||
status and headers
|
||||
|
||||
Returns:
|
||||
list[bytes]: iterable containing bytes to be returned in
|
||||
HTTP response body
|
||||
|
||||
"""
|
||||
path = environ['PATH_INFO'] or '/'
|
||||
for p, app in self.apps:
|
||||
# The apps list should be sorted by length, descending.
|
||||
if path.startswith('{path!s}/'.format(path=p)) or path == p:
|
||||
environ = environ.copy()
|
||||
environ['SCRIPT_NAME'] = environ.get('SCRIPT_NAME', '') + p
|
||||
environ['PATH_INFO'] = path[len(p):]
|
||||
return app(environ, start_response)
|
||||
|
||||
start_response(
|
||||
'404 Not Found', [
|
||||
('Content-Type', 'text/plain'),
|
||||
('Content-Length', '0'),
|
||||
],
|
||||
)
|
||||
return ['']
|
||||
|
||||
|
||||
# compatibility aliases
|
||||
globals().update(
|
||||
WSGIServer=Server,
|
||||
WSGIGateway=Gateway,
|
||||
WSGIGateway_u0=Gateway_u0,
|
||||
WSGIGateway_10=Gateway_10,
|
||||
WSGIPathInfoDispatcher=PathInfoDispatcher,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from . import server
|
||||
from typing import Any
|
||||
|
||||
class Server(server.HTTPServer):
|
||||
wsgi_version: Any
|
||||
wsgi_app: Any
|
||||
request_queue_size: Any
|
||||
timeout: Any
|
||||
shutdown_timeout: Any
|
||||
requests: Any
|
||||
def __init__(self, bind_addr, wsgi_app, numthreads: int = ..., server_name: Any | None = ..., max: int = ..., request_queue_size: int = ..., timeout: int = ..., shutdown_timeout: int = ..., accepted_queue_size: int = ..., accepted_queue_timeout: int = ..., peercreds_enabled: bool = ..., peercreds_resolve_enabled: bool = ...) -> None: ...
|
||||
@property
|
||||
def numthreads(self): ...
|
||||
@numthreads.setter
|
||||
def numthreads(self, value) -> None: ...
|
||||
|
||||
class Gateway(server.Gateway):
|
||||
started_response: bool
|
||||
env: Any
|
||||
remaining_bytes_out: Any
|
||||
def __init__(self, req) -> None: ...
|
||||
@classmethod
|
||||
def gateway_map(cls): ...
|
||||
def get_environ(self) -> None: ...
|
||||
def respond(self) -> None: ...
|
||||
def start_response(self, status, headers, exc_info: Any | None = ...): ...
|
||||
def write(self, chunk) -> None: ...
|
||||
|
||||
class Gateway_10(Gateway):
|
||||
version: Any
|
||||
def get_environ(self): ...
|
||||
|
||||
class Gateway_u0(Gateway_10):
|
||||
version: Any
|
||||
def get_environ(self): ...
|
||||
|
||||
wsgi_gateways: Any
|
||||
|
||||
class PathInfoDispatcher:
|
||||
apps: Any
|
||||
def __init__(self, apps): ...
|
||||
def __call__(self, environ, start_response): ...
|
||||
Reference in New Issue
Block a user