diff options
author | Bert JW Regeer <xistence@0x58.com> | 2016-06-24 22:42:52 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-06-24 22:42:52 -0600 |
commit | 631b5fc855f5930f2766286fdf0d379a5b3973d1 (patch) | |
tree | 0e99921ad63e412e4a97f627e883a71b440b7b34 | |
parent | 60be8767a1b35c5561becbaf47f93e26632779c9 (diff) | |
parent | 3e35e6f9d63143341e089bcad4746aab1d5d3bca (diff) | |
download | waitress-631b5fc855f5930f2766286fdf0d379a5b3973d1.tar.gz |
Merge pull request #128 from Pylons/feature/multiple_sockets
Feature: multiple sockets
-rw-r--r-- | .travis.yml | 2 | ||||
-rw-r--r-- | CHANGES.txt | 41 | ||||
-rw-r--r-- | HISTORY.txt | 37 | ||||
-rw-r--r-- | appveyor.yml | 12 | ||||
-rw-r--r-- | docs/api.rst | 2 | ||||
-rw-r--r-- | docs/arguments.rst | 32 | ||||
-rw-r--r-- | docs/index.rst | 27 | ||||
-rw-r--r-- | docs/runner.rst | 25 | ||||
-rw-r--r-- | setup.py | 5 | ||||
-rw-r--r-- | tox.ini | 3 | ||||
-rw-r--r-- | waitress/__init__.py | 3 | ||||
-rw-r--r-- | waitress/adjustments.py | 115 | ||||
-rw-r--r-- | waitress/runner.py | 37 | ||||
-rw-r--r-- | waitress/server.py | 152 | ||||
-rw-r--r-- | waitress/tests/test_adjustments.py | 130 | ||||
-rw-r--r-- | waitress/tests/test_functional.py | 4 | ||||
-rw-r--r-- | waitress/tests/test_server.py | 36 |
17 files changed, 580 insertions, 83 deletions
diff --git a/.travis.yml b/.travis.yml index 7390683..7c837bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,6 @@ sudo: false matrix: include: - - python: 2.6 - env: TOXENV=py26 - python: 2.7 env: TOXENV=py27 - python: 3.3 diff --git a/CHANGES.txt b/CHANGES.txt index ba9ae51..7a16e9d 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,36 +1,21 @@ -0.9.0 (2016-04-15) ------------------- +Unreleased +---------- Deprecations ~~~~~~~~~~~~ -- Python 3.2 is no longer supported by Waitress. +- Python 2.6 is no longer supported. -- Python 2.6 will no longer be supported by Waitress in future releases. - -Security/Protections -~~~~~~~~~~~~~~~~~~~~ - -- Building on the changes made in pull request 117, add in checking for line - feed/carriage return HTTP Response Splitting in the status line, as well as - the key of a header. See https://github.com/Pylons/waitress/pull/124 and - https://github.com/Pylons/waitress/issues/122. - -- Waitress will no longer accept headers or status lines with - newline/carriage returns in them, thereby disallowing HTTP Response - Splitting. See https://github.com/Pylons/waitress/issues/117 for - more information, as well as - https://www.owasp.org/index.php/HTTP_Response_Splitting. - -Bugfixes +Features ~~~~~~~~ -- FileBasedBuffer and more important ReadOnlyFileBasedBuffer no longer report - False when tested with bool(), instead always returning True, and becoming - more iterator like. - See: https://github.com/Pylons/waitress/pull/82 and - https://github.com/Pylons/waitress/issues/76 +- Waitress is now able to listen on multiple sockets, including IPv4 and IPv6. + Instead of passing in a host/port combination you now provide waitress with a + space delineated list, and it will create as many sockets as required. Using + the host/port combination is deprecated but will be supported for at least + the next 5 minor releases. + + .. code-block:: python -- Call prune() on the output buffer at the end of a request so that it doesn't - continue to grow without bounds. See - https://github.com/Pylons/waitress/issues/111 for more information. + from waitress import serve + serve(wsgiapp, listen='0.0.0.0:8080 [::]:9090 *:6543') diff --git a/HISTORY.txt b/HISTORY.txt index 176a654..9932582 100644 --- a/HISTORY.txt +++ b/HISTORY.txt @@ -1,3 +1,40 @@ +0.9.0 (2016-04-15) +------------------ + +Deprecations +~~~~~~~~~~~~ + +- Python 3.2 is no longer supported by Waitress. + +- Python 2.6 will no longer be supported by Waitress in future releases. + +Security/Protections +~~~~~~~~~~~~~~~~~~~~ + +- Building on the changes made in pull request 117, add in checking for line + feed/carriage return HTTP Response Splitting in the status line, as well as + the key of a header. See https://github.com/Pylons/waitress/pull/124 and + https://github.com/Pylons/waitress/issues/122. + +- Waitress will no longer accept headers or status lines with + newline/carriage returns in them, thereby disallowing HTTP Response + Splitting. See https://github.com/Pylons/waitress/issues/117 for + more information, as well as + https://www.owasp.org/index.php/HTTP_Response_Splitting. + +Bugfixes +~~~~~~~~ + +- FileBasedBuffer and more important ReadOnlyFileBasedBuffer no longer report + False when tested with bool(), instead always returning True, and becoming + more iterator like. + See: https://github.com/Pylons/waitress/pull/82 and + https://github.com/Pylons/waitress/issues/76 + +- Call prune() on the output buffer at the end of a request so that it doesn't + continue to grow without bounds. See + https://github.com/Pylons/waitress/issues/111 for more information. + 0.8.10 (2015-09-02) ------------------- diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..1350507 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,12 @@ +environment: + matrix: + - PYTHON: "C:\\Python35" + TOXENV: "py35" + +install: + - "%PYTHON%\\python.exe -m pip install tox" + +build: off + +test_script: + - "%PYTHON%\\Scripts\\tox.exe" diff --git a/docs/api.rst b/docs/api.rst index c85d4e1..5e0a523 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,6 +5,6 @@ .. module:: waitress -.. function:: serve(app, host='0.0.0.0', port=8080, unix_socket=None, unix_socket_perms='600', threads=4, url_scheme='http', url_prefix='', ident='waitress', backlog=1204, recv_bytes=8192, send_bytes=18000, outbuf_overflow=104856, inbuf_overflow=52488, connection_limit=1000, cleanup_interval=30, channel_timeout=120, log_socket_errors=True, max_request_header_size=262144, max_request_body_size=1073741824, expose_tracebacks=False) +.. function:: serve(app, listen='0.0.0.0:8080', unix_socket=None, unix_socket_perms='600', threads=4, url_scheme='http', url_prefix='', ident='waitress', backlog=1204, recv_bytes=8192, send_bytes=18000, outbuf_overflow=104856, inbuf_overflow=52488, connection_limit=1000, cleanup_interval=30, channel_timeout=120, log_socket_errors=True, max_request_header_size=262144, max_request_body_size=1073741824, expose_tracebacks=False) See :ref:`arguments` for more information. diff --git a/docs/arguments.rst b/docs/arguments.rst index cc74c0c..d6d75ce 100644 --- a/docs/arguments.rst +++ b/docs/arguments.rst @@ -10,9 +10,41 @@ host hostname or IP address (string) on which to listen, default ``0.0.0.0``, which means "all IP addresses on this host". + .. warning:: + May not be used with ``listen`` + + .. deprecated:: 1.0 + port TCP port (integer) on which to listen, default ``8080`` + .. warning:: + May not be used with ``listen`` + + .. deprecated:: 1.0 + +listen + Tell waitress to listen on an host/port combination. It is to be provided + as a space delineated list of host/port: + + Examples: + + - ``listen="127.0.0.1:8080 [::1]:8080"`` + - ``listen="*:8080 *:6543"`` + + A wildcard for the hostname is also supported and will bind to both + IPv4/IPv6 depending on whether they are enabled or disabled. + + IPv6 IP addresses are supported by surrounding the IP address with brackets. + + .. versionadded:: 1.0 + +ipv4 + Enable or disable IPv4 (boolean) + +ipv6 + Enable or disable IPv6 (boolean) + unix_socket Path of Unix socket (string), default is ``None``. If a socket path is specified, a Unix domain socket is made instead of the usual inet domain diff --git a/docs/index.rst b/docs/index.rst index 6a595ad..9d8a812 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,18 +15,19 @@ Here's normal usage of the server: .. code-block:: python from waitress import serve - serve(wsgiapp, host='0.0.0.0', port=8080) + serve(wsgiapp, listen='*:8080') -If you want to serve your application on all IP addresses, on port 8080, you -can omit the ``host`` and ``port`` arguments and just call ``serve`` with the -WSGI app as a single argument: +This will run waitress on port 8080 on all available IP addresses, both IPv4 +and IPv6. + +Press Ctrl-C (or Ctrl-Break on Windows) to exit the server. + +The default is to bind to any IPv4 address on port 8080: .. code-block:: python from waitress import serve serve(wsgiapp) - -Press Ctrl-C (or Ctrl-Break on Windows) to exit the server. If you want to serve your application through a UNIX domain socket (to serve a downstream HTTP server/proxy, e.g. nginx, lighttpd, etc.), call ``serve`` @@ -49,8 +50,7 @@ lets you use Waitress's WSGI gateway from a configuration file, e.g.: [server:main] use = egg:waitress#main - host = 127.0.0.1 - port = 8080 + listen = 127.0.0.1:8080 The :term:`PasteDeploy` syntax for UNIX domain sockets is analagous: @@ -67,7 +67,7 @@ Additionally, there is a command line runner called ``waitress-serve``, which can be used in development and in situations where the likes of :term:`PasteDeploy` is not necessary:: - waitress-serve --port=8041 myapp:wsgifunc + waitress-serve --listen=*:8041 myapp:wsgifunc For more information on this, see :ref:`runner`. @@ -153,7 +153,7 @@ You can have the Waitress server use the ``https`` url scheme by default.: .. code-block:: python from waitress import serve - serve(wsgiapp, host='0.0.0.0', port=8080, url_scheme='https') + serve(wsgiapp, listen='0.0.0.0:8080', url_scheme='https') This works if all URLs generated by your application should use the ``https`` scheme. @@ -188,7 +188,7 @@ account.: .. code-block:: python from waitress import serve - serve(wsgiapp, host='0.0.0.0', port=8080, url_prefix='/foo') + serve(wsgiapp, listen='0.0.0.0:8080', url_prefix='/foo') Setting this to any value except the empty string will cause the WSGI ``SCRIPT_NAME`` value to be that value, minus any trailing slashes you add, and @@ -257,8 +257,7 @@ PasteDeploy-style configuration: [server:main] use = egg:waitress#main - host = 127.0.0.1 - port = 8080 + listen = 127.0.0.1:8080 Note that you can also set ``PATH_INFO`` and ``SCRIPT_NAME`` using PrefixMiddleware too (its original purpose, really) instead of using Waitress' @@ -287,8 +286,6 @@ Change History Known Issues ------------ -- Does not yet support IPv6 natively. - - Does not support SSL natively. Support and Development diff --git a/docs/runner.rst b/docs/runner.rst index 9799a17..88a7d63 100644 --- a/docs/runner.rst +++ b/docs/runner.rst @@ -85,6 +85,31 @@ Common options: ``--port=PORT`` TCP port on which to listen, default is '8080' +``--listen=host:port`` + Tell waitress to listen on an ip port combination. + + Example: + + --listen=127.0.0.1:8080 + --listen=[::1]:8080 + --listen=*:8080 + + This option may be used multiple times to listen on multipe sockets. + A wildcard for the hostname is also supported and will bind to both + IPv4/IPv6 depending on whether they are enabled or disabled. + +``--[no-]ipv4`` + Toggle on/off IPv4 support. + + This affects wildcard matching when listening on a wildcard address/port + combination. + +``--[no-]ipv6`` + Toggle on/off IPv6 support. + + This affects wildcard matching when listening on a wildcard address/port + combination. + ``--unix-socket=PATH`` Path of Unix socket. If a socket path is specified, a Unix domain socket is made instead of the usual inet domain socket. @@ -12,7 +12,6 @@ # ############################################################################## import os -import sys from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) @@ -33,9 +32,6 @@ testing_extras = [ 'coverage', ] -if sys.version_info[:2] == (2, 6): - testing_extras.append('unittest2') - setup( name='waitress', version='0.9.1dev0', @@ -54,7 +50,6 @@ setup( 'License :: OSI Approved :: Zope Public License', 'Programming Language :: Python', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', @@ -1,6 +1,6 @@ [tox] envlist = - py26,py27,py33,py34,py35,pypy,pypy3, + py27,py33,py34,py35,pypy,pypy3, docs, {py2,py3}-cover,coverage @@ -8,7 +8,6 @@ envlist = # Most of these are defaults but if you specify any you can't fall back # to defaults for others. basepython = - py26: python2.6 py27: python2.7 py33: python3.3 py34: python3.4 diff --git a/waitress/__init__.py b/waitress/__init__.py index 27210d4..775fe3a 100644 --- a/waitress/__init__.py +++ b/waitress/__init__.py @@ -10,8 +10,7 @@ def serve(app, **kw): logging.basicConfig() server = _server(app, **kw) if not _quiet: # pragma: no cover - print('serving on http://%s:%s' % (server.effective_host, - server.effective_port)) + server.print_listen('Serving on http://{}:{}') if _profile: # pragma: no cover profile('server.run()', globals(), locals(), (), False) else: diff --git a/waitress/adjustments.py b/waitress/adjustments.py index d5b237b..f86ece5 100644 --- a/waitress/adjustments.py +++ b/waitress/adjustments.py @@ -15,7 +15,8 @@ """ import getopt import socket -import sys + +from waitress.compat import string_types truthy = frozenset(('t', 'true', 'y', 'yes', 'on', '1')) @@ -36,6 +37,22 @@ def asoctal(s): """Convert the given octal string to an actual number.""" return int(s, 8) +def aslist_cronly(value): + if isinstance(value, string_types): + value = filter(None, [x.strip() for x in value.splitlines()]) + return list(value) + +def aslist(value): + """ Return a list of strings, separating the input based on newlines + and, if flatten=True (the default), also split on spaces within + each line.""" + values = aslist_cronly(value) + result = [] + for value in values: + subvalues = value.split() + result.extend(subvalues) + return result + def slash_fixed_str(s): s = s.strip() if s: @@ -44,6 +61,12 @@ def slash_fixed_str(s): s = '/' + s.lstrip('/').rstrip('/') return s +class _str_marker(str): + pass + +class _int_marker(int): + pass + class Adjustments(object): """This class contains tunable parameters. """ @@ -51,6 +74,9 @@ class Adjustments(object): _params = ( ('host', str), ('port', int), + ('ipv4', asbool), + ('ipv6', asbool), + ('listen', aslist), ('threads', int), ('trusted_proxy', str), ('url_scheme', str), @@ -77,10 +103,12 @@ class Adjustments(object): _param_map = dict(_params) # hostname or IP address to listen on - host = '0.0.0.0' + host = _str_marker('0.0.0.0') # TCP port to listen on - port = 8080 + port = _int_marker(8080) + + listen = ['{}:{}'.format(host, port)] # mumber of threads available for tasks threads = 4 @@ -174,14 +202,82 @@ class Adjustments(object): # The asyncore.loop flag to use poll() instead of the default select(). asyncore_use_poll = False + # Enable IPv4 by default + ipv4 = True + + # Enable IPv6 by default + ipv6 = True + def __init__(self, **kw): + + if 'listen' in kw and ('host' in kw or 'port' in kw): + raise ValueError('host and or port may not be set if listen is set.') + for k, v in kw.items(): if k not in self._param_map: raise ValueError('Unknown adjustment %r' % k) setattr(self, k, self._param_map[k](v)) - if (sys.platform[:3] == "win" and - self.host == 'localhost'): # pragma: no cover - self.host = '' + + if (not isinstance(self.host, _str_marker) or + not isinstance(self.port, _int_marker)): + self.listen = ['{}:{}'.format(self.host, self.port)] + + enabled_families = socket.AF_UNSPEC + + if self.ipv4 and not self.ipv6: + enabled_families = socket.AF_INET + + if not self.ipv4 and self.ipv6: + enabled_families = socket.AF_INET6 + + wanted_sockets = [] + hp_pairs = [] + for i in self.listen: + if ':' in i: + (host, port) = i.rsplit(":", 1) + + # IPv6 we need to make sure that we didn't split on the address + if ']' in port: # pragma: nocover + (host, port) = (i, str(self.port)) + else: + (host, port) = (i, str(self.port)) + + try: + if '[' in host and ']' in host: # pragma: nocover + host = host.strip('[').rstrip(']') + + if host == '*': + host = None + + for s in socket.getaddrinfo( + host, + port, + enabled_families, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + socket.AI_PASSIVE | socket.AI_ADDRCONFIG + ): + (family, socktype, proto, _, sockaddr) = s + + # It seems that getaddrinfo() may sometimes happily return + # the same result multiple times, this of course makes + # bind() very unhappy... + # + # Split on %, and drop the zone-index from the host in the + # sockaddr. Works around a bug in OS X whereby + # getaddrinfo() returns the same link-local interface with + # two different zone-indices (which makes no sense what so + # ever...) yet treats them equally when we attempt to bind(). + if ( + sockaddr[1] == 0 or + (sockaddr[0].split('%', 1)[0], sockaddr[1]) not in hp_pairs + ): + wanted_sockets.append((family, socktype, proto, sockaddr)) + hp_pairs.append((sockaddr[0].split('%', 1)[0], sockaddr[1])) + except: + raise ValueError('Invalid host/port specified.') + + self.listen = wanted_sockets @classmethod def parse_args(cls, argv): @@ -203,9 +299,15 @@ class Adjustments(object): 'help': False, 'call': False, } + opts, args = getopt.getopt(argv, '', long_opts) for opt, value in opts: param = opt.lstrip('-').replace('-', '_') + + if param == 'listen': + kw['listen'] = '{} {}'.format(kw.get('listen', ''), value) + continue + if param.startswith('no_'): param = param[3:] kw[param] = 'false' @@ -215,4 +317,5 @@ class Adjustments(object): kw[param] = 'true' else: kw[param] = value + return kw, args diff --git a/waitress/runner.py b/waitress/runner.py index 04cd78f..abdb38e 100644 --- a/waitress/runner.py +++ b/waitress/runner.py @@ -42,9 +42,46 @@ Standard options: Hostname or IP address on which to listen, default is '0.0.0.0', which means "all IP addresses on this host". + Note: May not be used together with --listen + --port=PORT TCP port on which to listen, default is '8080' + Note: May not be used together with --listen + + --listen=ip:port + Tell waitress to listen on an ip port combination. + + Example: + + --listen=127.0.0.1:8080 + --listen=[::1]:8080 + --listen=*:8080 + + This option may be used multiple times to listen on multipe sockets. + A wildcard for the hostname is also supported and will bind to both + IPv4/IPv6 depending on whether they are enabled or disabled. + + --[no-]ipv4 + Toggle on/off IPv4 support. + + Example: + + --no-ipv4 + + This will disable IPv4 socket support. This affects wildcard matching + when generating the list of sockets. + + --[no-]ipv6 + Toggle on/off IPv6 support. + + Example: + + --no-ipv6 + + This will turn on IPv6 socket support. This affects wildcard matching + when generating a list of sockets. + --unix-socket=PATH Path of Unix socket. If a socket path is specified, a Unix domain socket is made instead of the usual inet domain socket. diff --git a/waitress/server.py b/waitress/server.py index 87338c8..7134f86 100644 --- a/waitress/server.py +++ b/waitress/server.py @@ -42,11 +42,90 @@ def create_server(application, 'to return a WSGI app within your application.' ) adj = Adjustments(**kw) + + if map is None: # pragma: nocover + map = {} + + dispatcher = _dispatcher + if dispatcher is None: + dispatcher = ThreadedTaskDispatcher() + dispatcher.set_thread_count(adj.threads) + if adj.unix_socket and hasattr(socket, 'AF_UNIX'): - cls = UnixWSGIServer - else: - cls = TcpWSGIServer - return cls(application, map, _start, _sock, _dispatcher, adj) + sockinfo = (socket.AF_UNIX, socket.SOCK_STREAM, None, None) + return UnixWSGIServer( + application, + map, + _start, + _sock, + dispatcher=dispatcher, + adj=adj, + sockinfo=sockinfo) + + effective_listen = [] + last_serv = None + for sockinfo in adj.listen: + # When TcpWSGIServer is called, it registers itself in the map. This + # side-effect is all we need it for, so we don't store a reference to + # or return it to the user. + last_serv = TcpWSGIServer( + application, + map, + _start, + _sock, + dispatcher=dispatcher, + adj=adj, + sockinfo=sockinfo) + effective_listen.append((last_serv.effective_host, last_serv.effective_port)) + + # We are running a single server, so we can just return the last server, + # saves us from having to create one more object + if len(adj.listen) == 1: + # In this case we have no need to use a MultiSocketServer + return last_serv + + # Return a class that has a utility function to print out the sockets it's + # listening on, and has a .run() function. All of the TcpWSGIServers + # registered themselves in the map above. + return MultiSocketServer(map, adj, effective_listen, dispatcher) + + +# This class is only ever used if we have multiple listen sockets. It allows +# the serve() API to call .run() which starts the asyncore loop, and catches +# SystemExit/KeyboardInterrupt so that it can atempt to cleanly shut down. +class MultiSocketServer(object): + asyncore = asyncore # test shim + + def __init__(self, + map=None, + adj=None, + effective_listen=None, + dispatcher=None, + ): + self.adj = adj + self.map = map + self.effective_listen = effective_listen + self.task_dispatcher = dispatcher + + def print_listen(self, format_str): # pragma: nocover + for l in self.effective_listen: + l = list(l) + + if ':' in l[0]: + l[0] = '[{}]'.format(l[0]) + + print(format_str.format(*l)) + + def run(self): + try: + self.asyncore.loop( + timeout=self.adj.asyncore_loop_timeout, + map=self.map, + use_poll=self.adj.asyncore_use_poll, + ) + except (SystemExit, KeyboardInterrupt): + self.task_dispatcher.shutdown() + class BaseWSGIServer(logging_dispatcher, object): @@ -54,15 +133,15 @@ class BaseWSGIServer(logging_dispatcher, object): next_channel_cleanup = 0 socketmod = socket # test shim asyncore = asyncore # test shim - family = None def __init__(self, application, map=None, _start=True, # test shim _sock=None, # test shim - _dispatcher=None, # test shim + dispatcher=None, # dispatcher adj=None, # adjustments + sockinfo=None, # opaque object **kw ): if adj is None: @@ -72,20 +151,30 @@ class BaseWSGIServer(logging_dispatcher, object): # conflicts with apps and libs that use the asyncore global socket # map ala https://github.com/Pylons/waitress/issues/63 map = {} + if sockinfo is None: + sockinfo = adj.listen[0] + + self.sockinfo = sockinfo + self.family = sockinfo[0] + self.socktype = sockinfo[1] self.application = application self.adj = adj self.trigger = trigger.trigger(map) - if _dispatcher is None: - _dispatcher = ThreadedTaskDispatcher() - _dispatcher.set_thread_count(self.adj.threads) - self.task_dispatcher = _dispatcher + if dispatcher is None: + dispatcher = ThreadedTaskDispatcher() + dispatcher.set_thread_count(self.adj.threads) + + self.task_dispatcher = dispatcher self.asyncore.dispatcher.__init__(self, _sock, map=map) if _sock is None: - self.create_socket(self.family, socket.SOCK_STREAM) + self.create_socket(self.family, self.socktype) + if self.family == socket.AF_INET6: # pragma: nocover + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + self.set_reuse_addr() self.bind_server_socket() self.effective_host, self.effective_port = self.getsockname() - self.server_name = self.get_server_name(self.adj.host) + self.server_name = self.get_server_name(self.effective_host) self.active_channels = {} if _start: self.accept_connections() @@ -99,12 +188,13 @@ class BaseWSGIServer(logging_dispatcher, object): server_name = str(ip) else: server_name = str(self.socketmod.gethostname()) + # Convert to a host name if necessary. for c in server_name: if c != '.' and not c.isdigit(): return server_name try: - if server_name == '0.0.0.0': + if server_name == '0.0.0.0' or server_name == '::': return 'localhost' server_name = self.socketmod.gethostbyaddr(server_name)[0] except socket.error: # pragma: no cover @@ -186,25 +276,51 @@ class BaseWSGIServer(logging_dispatcher, object): if (not channel.requests) and channel.last_activity < cutoff: channel.will_close = True -class TcpWSGIServer(BaseWSGIServer): + def print_listen(self, format_str): # pragma: nocover + print(format_str.format(self.effective_host, self.effective_port)) - family = socket.AF_INET + +class TcpWSGIServer(BaseWSGIServer): def bind_server_socket(self): - self.bind((self.adj.host, self.adj.port)) + (_, _, _, sockaddr) = self.sockinfo + self.bind(sockaddr) def getsockname(self): - return self.socket.getsockname() + return self.socketmod.getnameinfo( + self.socket.getsockname(), + self.socketmod.NI_NUMERICSERV) def set_socket_options(self, conn): for (level, optname, value) in self.adj.socket_options: conn.setsockopt(level, optname, value) + if hasattr(socket, 'AF_UNIX'): class UnixWSGIServer(BaseWSGIServer): - family = socket.AF_UNIX + def __init__(self, + application, + map=None, + _start=True, # test shim + _sock=None, # test shim + dispatcher=None, # dispatcher + adj=None, # adjustments + sockinfo=None, # opaque object + **kw): + if sockinfo is None: + sockinfo = (socket.AF_UNIX, socket.SOCK_STREAM, None, None) + + super(UnixWSGIServer, self).__init__( + application, + map=map, + _start=_start, + _sock=_sock, + dispatcher=dispatcher, + adj=adj, + sockinfo=sockinfo, + **kw) def bind_server_socket(self): cleanup_unix_socket(self.adj.unix_socket) diff --git a/waitress/tests/test_adjustments.py b/waitress/tests/test_adjustments.py index f2b28c2..5c8985a 100644 --- a/waitress/tests/test_adjustments.py +++ b/waitress/tests/test_adjustments.py @@ -1,4 +1,5 @@ import sys +import socket if sys.version_info[:2] == (2, 6): # pragma: no cover import unittest2 as unittest @@ -45,13 +46,35 @@ class Test_asbool(unittest.TestCase): class TestAdjustments(unittest.TestCase): + def _hasIPv6(self): # pragma: nocover + if not socket.has_ipv6: + return False + + try: + socket.getaddrinfo( + '::1', + 0, + socket.AF_UNSPEC, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + socket.AI_PASSIVE | socket.AI_ADDRCONFIG + ) + + return True + except socket.gaierror as e: + # Check to see what the error is + if e.errno == socket.EAI_ADDRFAMILY: + return False + else: + raise e + def _makeOne(self, **kw): from waitress.adjustments import Adjustments return Adjustments(**kw) def test_goodvars(self): inst = self._makeOne( - host='host', + host='localhost', port='8080', threads='5', trusted_proxy='192.168.1.1', @@ -74,8 +97,11 @@ class TestAdjustments(unittest.TestCase): unix_socket='/tmp/waitress.sock', unix_socket_perms='777', url_prefix='///foo/', + ipv4=True, + ipv6=False, ) - self.assertEqual(inst.host, 'host') + + self.assertEqual(inst.host, 'localhost') self.assertEqual(inst.port, 8080) self.assertEqual(inst.threads, 5) self.assertEqual(inst.trusted_proxy, '192.168.1.1') @@ -98,10 +124,87 @@ class TestAdjustments(unittest.TestCase): self.assertEqual(inst.unix_socket, '/tmp/waitress.sock') self.assertEqual(inst.unix_socket_perms, 0o777) self.assertEqual(inst.url_prefix, '/foo') + self.assertEqual(inst.ipv4, True) + self.assertEqual(inst.ipv6, False) + + bind_pairs = [ + sockaddr[:2] + for (family, _, _, sockaddr) in inst.listen + if family == socket.AF_INET + ] + + # On Travis, somehow we start listening to two sockets when resolving + # localhost... + self.assertEqual(('127.0.0.1', 8080), bind_pairs[0]) + + def test_goodvar_listen(self): + inst = self._makeOne(listen='127.0.0.1') + + bind_pairs = [(host, port) for (_, _, _, (host, port)) in inst.listen] + + self.assertEqual(bind_pairs, [('127.0.0.1', 8080)]) + + def test_default_listen(self): + inst = self._makeOne() + + bind_pairs = [(host, port) for (_, _, _, (host, port)) in inst.listen] + + self.assertEqual(bind_pairs, [('0.0.0.0', 8080)]) + + def test_multiple_listen(self): + inst = self._makeOne(listen='127.0.0.1:9090 127.0.0.1:8080') + + bind_pairs = [sockaddr[:2] for (_, _, _, sockaddr) in inst.listen] + + self.assertEqual(bind_pairs, + [('127.0.0.1', 9090), + ('127.0.0.1', 8080)]) + + def test_wildcard_listen(self): + inst = self._makeOne(listen='*:8080') + + bind_pairs = [sockaddr[:2] for (_, _, _, sockaddr) in inst.listen] + + self.assertTrue(len(bind_pairs) >= 1) + + def test_ipv6_no_port(self): # pragma: nocover + if not self._hasIPv6(): + return + + inst = self._makeOne(listen='[::1]') + + bind_pairs = [sockaddr[:2] for (_, _, _, sockaddr) in inst.listen] + + self.assertEqual(bind_pairs, [('::1', 8080)]) + + def test_bad_port(self): + self.assertRaises(ValueError, self._makeOne, listen='127.0.0.1:test') + + def test_service_port(self): + inst = self._makeOne(listen='127.0.0.1:http 0.0.0.0:https') + + bind_pairs = [sockaddr[:2] for (_, _, _, sockaddr) in inst.listen] + + self.assertEqual(bind_pairs, [('127.0.0.1', 80), ('0.0.0.0', 443)]) + + def test_dont_mix_host_port_listen(self): + self.assertRaises( + ValueError, + self._makeOne, + host='localhost', + port='8080', + listen='127.0.0.1:8080', + ) def test_badvar(self): self.assertRaises(ValueError, self._makeOne, nope=True) + def test_ipv4_disabled(self): + self.assertRaises(ValueError, self._makeOne, ipv4=False, listen="127.0.0.1:8080") + + def test_ipv6_disabled(self): + self.assertRaises(ValueError, self._makeOne, ipv6=False, listen="[::]:8080") + class TestCLI(unittest.TestCase): def parse(self, argv): @@ -147,10 +250,31 @@ class TestCLI(unittest.TestCase): self.assertDictContainsSubset({ 'host': 'localhost', 'port': '80', - 'unix_socket_perms':'777', + 'unix_socket_perms': '777', }, opts) self.assertSequenceEqual(args, []) + def test_listen_params(self): + opts, args = self.parse([ + '--listen=test:80', + ]) + + self.assertDictContainsSubset({ + 'listen': ' test:80' + }, opts) + self.assertSequenceEqual(args, []) + + def test_multiple_listen_params(self): + opts, args = self.parse([ + '--listen=test:80', + '--listen=test:8080', + ]) + + self.assertDictContainsSubset({ + 'listen': ' test:80 test:8080' + }, opts) + self.assertSequenceEqual(args, []) + def test_bad_param(self): import getopt self.assertRaises(getopt.GetoptError, self.parse, ['--no-host']) diff --git a/waitress/tests/test_functional.py b/waitress/tests/test_functional.py index 020486a..59ef4e4 100644 --- a/waitress/tests/test_functional.py +++ b/waitress/tests/test_functional.py @@ -34,6 +34,8 @@ class FixtureTcpWSGIServer(server.TcpWSGIServer): """A version of TcpWSGIServer that relays back what it's bound to. """ + family = socket.AF_INET # Testing + def __init__(self, application, queue, **kw): # pragma: no cover # Coverage doesn't see this as it's ran in a separate process. kw['port'] = 0 # Bind to any available port. @@ -1386,6 +1388,8 @@ if hasattr(socket, 'AF_UNIX'): """A version of UnixWSGIServer that relays back what it's bound to. """ + family = socket.AF_UNIX # Testing + def __init__(self, application, queue, **kw): # pragma: no cover # Coverage doesn't see this as it's ran in a separate process. # To permit parallel testing, use a PID-dependent socket. diff --git a/waitress/tests/test_server.py b/waitress/tests/test_server.py index 0ff8871..39b90b3 100644 --- a/waitress/tests/test_server.py +++ b/waitress/tests/test_server.py @@ -34,10 +34,23 @@ class TestWSGIServer(unittest.TestCase): _start=_start, ) + def _makeOneWithMulti(self, adj=None, _start=True, + app=dummy_app, listen="127.0.0.1:0 127.0.0.1:0"): + sock = DummySock() + task_dispatcher = DummyTaskDispatcher() + map = {} + from waitress.server import create_server + return create_server( + app, + listen=listen, + map=map, + _dispatcher=task_dispatcher, + _start=_start, + _sock=sock) + def test_ctor_app_is_None(self): self.assertRaises(ValueError, self._makeOneWithMap, app=None) - def test_ctor_start_true(self): inst = self._makeOneWithMap(_start=True) self.assertEqual(inst.accepting, True) @@ -72,6 +85,10 @@ class TestWSGIServer(unittest.TestCase): result = inst.get_server_name('0.0.0.0') self.assertEqual(result, 'localhost') + def test_get_server_multi(self): + inst = self._makeOneWithMulti() + self.assertEqual(inst.__class__.__name__, 'MultiSocketServer') + def test_run(self): inst = self._makeOneWithMap(_start=False) inst.asyncore = DummyAsyncore() @@ -79,6 +96,13 @@ class TestWSGIServer(unittest.TestCase): inst.run() self.assertTrue(inst.task_dispatcher.was_shutdown) + def test_run_base_server(self): + inst = self._makeOneWithMulti(_start=False) + inst.asyncore = DummyAsyncore() + inst.task_dispatcher = DummyTaskDispatcher() + inst.run() + self.assertTrue(inst.task_dispatcher.was_shutdown) + def test_pull_trigger(self): inst = self._makeOneWithMap(_start=False) inst.trigger = DummyTrigger() @@ -242,6 +266,16 @@ if hasattr(socket, 'AF_UNIX'): [(inst, client, ('localhost', None), inst.adj)] ) + def test_creates_new_sockinfo(self): + from waitress.server import UnixWSGIServer + inst = UnixWSGIServer( + dummy_app, + unix_socket=self.unix_socket, + unix_socket_perms='600' + ) + + self.assertEqual(inst.sockinfo[0], socket.AF_UNIX) + class DummySock(object): accepted = False blocking = False |