1##############################################################################
2#
3# Copyright (c) 2002 Zope Foundation and Contributors.
4# All Rights Reserved.
5#
6# This software is subject to the provisions of the Zope Public License,
7# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
8# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
9# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
10# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
11# FOR A PARTICULAR PURPOSE.
12#
13##############################################################################
14"""Adjustments are tunable parameters.
15"""
16import getopt
17import socket
18import warnings
19
20from .compat import HAS_IPV6, WIN
21from .proxy_headers import PROXY_HEADERS
22
23truthy = frozenset(("t", "true", "y", "yes", "on", "1"))
24
25KNOWN_PROXY_HEADERS = frozenset(
26    header.lower().replace("_", "-") for header in PROXY_HEADERS
27)
28
29
30def asbool(s):
31    """Return the boolean value ``True`` if the case-lowered value of string
32    input ``s`` is any of ``t``, ``true``, ``y``, ``on``, or ``1``, otherwise
33    return the boolean value ``False``.  If ``s`` is the value ``None``,
34    return ``False``.  If ``s`` is already one of the boolean values ``True``
35    or ``False``, return it."""
36    if s is None:
37        return False
38    if isinstance(s, bool):
39        return s
40    s = str(s).strip()
41    return s.lower() in truthy
42
43
44def asoctal(s):
45    """Convert the given octal string to an actual number."""
46    return int(s, 8)
47
48
49def aslist_cronly(value):
50    if isinstance(value, str):
51        value = filter(None, [x.strip() for x in value.splitlines()])
52    return list(value)
53
54
55def aslist(value):
56    """Return a list of strings, separating the input based on newlines
57    and, if flatten=True (the default), also split on spaces within
58    each line."""
59    values = aslist_cronly(value)
60    result = []
61    for value in values:
62        subvalues = value.split()
63        result.extend(subvalues)
64    return result
65
66
67def asset(value):
68    return set(aslist(value))
69
70
71def slash_fixed_str(s):
72    s = s.strip()
73    if s:
74        # always have a leading slash, replace any number of leading slashes
75        # with a single slash, and strip any trailing slashes
76        s = "/" + s.lstrip("/").rstrip("/")
77    return s
78
79
80def str_iftruthy(s):
81    return str(s) if s else None
82
83
84def as_socket_list(sockets):
85    """Checks if the elements in the list are of type socket and
86    removes them if not."""
87    return [sock for sock in sockets if isinstance(sock, socket.socket)]
88
89
90class _str_marker(str):
91    pass
92
93
94class _int_marker(int):
95    pass
96
97
98class _bool_marker:
99    pass
100
101
102class Adjustments:
103    """This class contains tunable parameters."""
104
105    _params = (
106        ("host", str),
107        ("port", int),
108        ("ipv4", asbool),
109        ("ipv6", asbool),
110        ("listen", aslist),
111        ("threads", int),
112        ("trusted_proxy", str_iftruthy),
113        ("trusted_proxy_count", int),
114        ("trusted_proxy_headers", asset),
115        ("log_untrusted_proxy_headers", asbool),
116        ("clear_untrusted_proxy_headers", asbool),
117        ("url_scheme", str),
118        ("url_prefix", slash_fixed_str),
119        ("backlog", int),
120        ("recv_bytes", int),
121        ("send_bytes", int),
122        ("outbuf_overflow", int),
123        ("outbuf_high_watermark", int),
124        ("inbuf_overflow", int),
125        ("connection_limit", int),
126        ("cleanup_interval", int),
127        ("channel_timeout", int),
128        ("log_socket_errors", asbool),
129        ("max_request_header_size", int),
130        ("max_request_body_size", int),
131        ("expose_tracebacks", asbool),
132        ("ident", str_iftruthy),
133        ("asyncore_loop_timeout", int),
134        ("asyncore_use_poll", asbool),
135        ("unix_socket", str),
136        ("unix_socket_perms", asoctal),
137        ("sockets", as_socket_list),
138        ("channel_request_lookahead", int),
139        ("server_name", str),
140    )
141
142    _param_map = dict(_params)
143
144    # hostname or IP address to listen on
145    host = _str_marker("0.0.0.0")
146
147    # TCP port to listen on
148    port = _int_marker(8080)
149
150    listen = ["{}:{}".format(host, port)]
151
152    # number of threads available for tasks
153    threads = 4
154
155    # Host allowed to overrid ``wsgi.url_scheme`` via header
156    trusted_proxy = None
157
158    # How many proxies we trust when chained
159    #
160    # X-Forwarded-For: 192.0.2.1, "[2001:db8::1]"
161    #
162    # or
163    #
164    # Forwarded: for=192.0.2.1, For="[2001:db8::1]"
165    #
166    # means there were (potentially), two proxies involved. If we know there is
167    # only 1 valid proxy, then that initial IP address "192.0.2.1" is not
168    # trusted and we completely ignore it. If there are two trusted proxies in
169    # the path, this value should be set to a higher number.
170    trusted_proxy_count = None
171
172    # Which of the proxy headers should we trust, this is a set where you
173    # either specify forwarded or one or more of forwarded-host, forwarded-for,
174    # forwarded-proto, forwarded-port.
175    trusted_proxy_headers = set()
176
177    # Would you like waitress to log warnings about untrusted proxy headers
178    # that were encountered while processing the proxy headers? This only makes
179    # sense to set when you have a trusted_proxy, and you expect the upstream
180    # proxy server to filter invalid headers
181    log_untrusted_proxy_headers = False
182
183    # Should waitress clear any proxy headers that are not deemed trusted from
184    # the environ? Change to True by default in 2.x
185    clear_untrusted_proxy_headers = _bool_marker
186
187    # default ``wsgi.url_scheme`` value
188    url_scheme = "http"
189
190    # default ``SCRIPT_NAME`` value, also helps reset ``PATH_INFO``
191    # when nonempty
192    url_prefix = ""
193
194    # server identity (sent in Server: header)
195    ident = "waitress"
196
197    # backlog is the value waitress passes to pass to socket.listen() This is
198    # the maximum number of incoming TCP connections that will wait in an OS
199    # queue for an available channel.  From listen(1): "If a connection
200    # request arrives when the queue is full, the client may receive an error
201    # with an indication of ECONNREFUSED or, if the underlying protocol
202    # supports retransmission, the request may be ignored so that a later
203    # reattempt at connection succeeds."
204    backlog = 1024
205
206    # recv_bytes is the argument to pass to socket.recv().
207    recv_bytes = 8192
208
209    # deprecated setting controls how many bytes will be buffered before
210    # being flushed to the socket
211    send_bytes = 1
212
213    # A tempfile should be created if the pending output is larger than
214    # outbuf_overflow, which is measured in bytes. The default is 1MB.  This
215    # is conservative.
216    outbuf_overflow = 1048576
217
218    # The app_iter will pause when pending output is larger than this value
219    # in bytes.
220    outbuf_high_watermark = 16777216
221
222    # A tempfile should be created if the pending input is larger than
223    # inbuf_overflow, which is measured in bytes. The default is 512K.  This
224    # is conservative.
225    inbuf_overflow = 524288
226
227    # Stop creating new channels if too many are already active (integer).
228    # Each channel consumes at least one file descriptor, and, depending on
229    # the input and output body sizes, potentially up to three.  The default
230    # is conservative, but you may need to increase the number of file
231    # descriptors available to the Waitress process on most platforms in
232    # order to safely change it (see ``ulimit -a`` "open files" setting).
233    # Note that this doesn't control the maximum number of TCP connections
234    # that can be waiting for processing; the ``backlog`` argument controls
235    # that.
236    connection_limit = 100
237
238    # Minimum seconds between cleaning up inactive channels.
239    cleanup_interval = 30
240
241    # Maximum seconds to leave an inactive connection open.
242    channel_timeout = 120
243
244    # Boolean: turn off to not log premature client disconnects.
245    log_socket_errors = True
246
247    # maximum number of bytes of all request headers combined (256K default)
248    max_request_header_size = 262144
249
250    # maximum number of bytes in request body (1GB default)
251    max_request_body_size = 1073741824
252
253    # expose tracebacks of uncaught exceptions
254    expose_tracebacks = False
255
256    # Path to a Unix domain socket to use.
257    unix_socket = None
258
259    # Path to a Unix domain socket to use.
260    unix_socket_perms = 0o600
261
262    # The socket options to set on receiving a connection.  It is a list of
263    # (level, optname, value) tuples.  TCP_NODELAY disables the Nagle
264    # algorithm for writes (Waitress already buffers its writes).
265    socket_options = [
266        (socket.SOL_TCP, socket.TCP_NODELAY, 1),
267    ]
268
269    # The asyncore.loop timeout value
270    asyncore_loop_timeout = 1
271
272    # The asyncore.loop flag to use poll() instead of the default select().
273    asyncore_use_poll = False
274
275    # Enable IPv4 by default
276    ipv4 = True
277
278    # Enable IPv6 by default
279    ipv6 = True
280
281    # A list of sockets that waitress will use to accept connections. They can
282    # be used for e.g. socket activation
283    sockets = []
284
285    # By setting this to a value larger than zero, each channel stays readable
286    # and continues to read requests from the client even if a request is still
287    # running, until the number of buffered requests exceeds this value.
288    # This allows detecting if a client closed the connection while its request
289    # is being processed.
290    channel_request_lookahead = 0
291
292    # This setting controls the SERVER_NAME of the WSGI environment, this is
293    # only ever used if the remote client sent a request without a Host header
294    # (or when using the Proxy settings, without forwarding a Host header)
295    server_name = "waitress.invalid"
296
297    def __init__(self, **kw):
298
299        if "listen" in kw and ("host" in kw or "port" in kw):
300            raise ValueError("host or port may not be set if listen is set.")
301
302        if "listen" in kw and "sockets" in kw:
303            raise ValueError("socket may not be set if listen is set.")
304
305        if "sockets" in kw and ("host" in kw or "port" in kw):
306            raise ValueError("host or port may not be set if sockets is set.")
307
308        if "sockets" in kw and "unix_socket" in kw:
309            raise ValueError("unix_socket may not be set if sockets is set")
310
311        if "unix_socket" in kw and ("host" in kw or "port" in kw):
312            raise ValueError("unix_socket may not be set if host or port is set")
313
314        if "unix_socket" in kw and "listen" in kw:
315            raise ValueError("unix_socket may not be set if listen is set")
316
317        if "send_bytes" in kw:
318            warnings.warn(
319                "send_bytes will be removed in a future release", DeprecationWarning
320            )
321
322        for k, v in kw.items():
323            if k not in self._param_map:
324                raise ValueError("Unknown adjustment %r" % k)
325            setattr(self, k, self._param_map[k](v))
326
327        if not isinstance(self.host, _str_marker) or not isinstance(
328            self.port, _int_marker
329        ):
330            self.listen = ["{}:{}".format(self.host, self.port)]
331
332        enabled_families = socket.AF_UNSPEC
333
334        if not self.ipv4 and not HAS_IPV6:  # pragma: no cover
335            raise ValueError(
336                "IPv4 is disabled but IPv6 is not available. Cowardly refusing to start."
337            )
338
339        if self.ipv4 and not self.ipv6:
340            enabled_families = socket.AF_INET
341
342        if not self.ipv4 and self.ipv6 and HAS_IPV6:
343            enabled_families = socket.AF_INET6
344
345        wanted_sockets = []
346        hp_pairs = []
347        for i in self.listen:
348            if ":" in i:
349                (host, port) = i.rsplit(":", 1)
350
351                # IPv6 we need to make sure that we didn't split on the address
352                if "]" in port:  # pragma: nocover
353                    (host, port) = (i, str(self.port))
354            else:
355                (host, port) = (i, str(self.port))
356
357            if WIN:  # pragma: no cover
358                try:
359                    # Try turning the port into an integer
360                    port = int(port)
361
362                except Exception:
363                    raise ValueError(
364                        "Windows does not support service names instead of port numbers"
365                    )
366
367            try:
368                if "[" in host and "]" in host:  # pragma: nocover
369                    host = host.strip("[").rstrip("]")
370
371                if host == "*":
372                    host = None
373
374                for s in socket.getaddrinfo(
375                    host,
376                    port,
377                    enabled_families,
378                    socket.SOCK_STREAM,
379                    socket.IPPROTO_TCP,
380                    socket.AI_PASSIVE,
381                ):
382                    (family, socktype, proto, _, sockaddr) = s
383
384                    # It seems that getaddrinfo() may sometimes happily return
385                    # the same result multiple times, this of course makes
386                    # bind() very unhappy...
387                    #
388                    # Split on %, and drop the zone-index from the host in the
389                    # sockaddr. Works around a bug in OS X whereby
390                    # getaddrinfo() returns the same link-local interface with
391                    # two different zone-indices (which makes no sense what so
392                    # ever...) yet treats them equally when we attempt to bind().
393                    if (
394                        sockaddr[1] == 0
395                        or (sockaddr[0].split("%", 1)[0], sockaddr[1]) not in hp_pairs
396                    ):
397                        wanted_sockets.append((family, socktype, proto, sockaddr))
398                        hp_pairs.append((sockaddr[0].split("%", 1)[0], sockaddr[1]))
399
400            except Exception:
401                raise ValueError("Invalid host/port specified.")
402
403        if self.trusted_proxy_count is not None and self.trusted_proxy is None:
404            raise ValueError(
405                "trusted_proxy_count has no meaning without setting " "trusted_proxy"
406            )
407
408        elif self.trusted_proxy_count is None:
409            self.trusted_proxy_count = 1
410
411        if self.trusted_proxy_headers and self.trusted_proxy is None:
412            raise ValueError(
413                "trusted_proxy_headers has no meaning without setting " "trusted_proxy"
414            )
415
416        if self.trusted_proxy_headers:
417            self.trusted_proxy_headers = {
418                header.lower() for header in self.trusted_proxy_headers
419            }
420
421            unknown_values = self.trusted_proxy_headers - KNOWN_PROXY_HEADERS
422            if unknown_values:
423                raise ValueError(
424                    "Received unknown trusted_proxy_headers value (%s) expected one "
425                    "of %s"
426                    % (", ".join(unknown_values), ", ".join(KNOWN_PROXY_HEADERS))
427                )
428
429            if (
430                "forwarded" in self.trusted_proxy_headers
431                and self.trusted_proxy_headers - {"forwarded"}
432            ):
433                raise ValueError(
434                    "The Forwarded proxy header and the "
435                    "X-Forwarded-{By,Host,Proto,Port,For} headers are mutually "
436                    "exclusive. Can't trust both!"
437                )
438
439        elif self.trusted_proxy is not None:
440            warnings.warn(
441                "No proxy headers were marked as trusted, but trusted_proxy was set. "
442                "Implicitly trusting X-Forwarded-Proto for backwards compatibility. "
443                "This will be removed in future versions of waitress.",
444                DeprecationWarning,
445            )
446            self.trusted_proxy_headers = {"x-forwarded-proto"}
447
448        if self.clear_untrusted_proxy_headers is _bool_marker:
449            warnings.warn(
450                "In future versions of Waitress clear_untrusted_proxy_headers will be "
451                "set to True by default. You may opt-out by setting this value to "
452                "False, or opt-in explicitly by setting this to True.",
453                DeprecationWarning,
454            )
455            self.clear_untrusted_proxy_headers = False
456
457        self.listen = wanted_sockets
458
459        self.check_sockets(self.sockets)
460
461    @classmethod
462    def parse_args(cls, argv):
463        """Pre-parse command line arguments for input into __init__.  Note that
464        this does not cast values into adjustment types, it just creates a
465        dictionary suitable for passing into __init__, where __init__ does the
466        casting.
467        """
468        long_opts = ["help", "call"]
469        for opt, cast in cls._params:
470            opt = opt.replace("_", "-")
471            if cast is asbool:
472                long_opts.append(opt)
473                long_opts.append("no-" + opt)
474            else:
475                long_opts.append(opt + "=")
476
477        kw = {
478            "help": False,
479            "call": False,
480        }
481
482        opts, args = getopt.getopt(argv, "", long_opts)
483        for opt, value in opts:
484            param = opt.lstrip("-").replace("-", "_")
485
486            if param == "listen":
487                kw["listen"] = "{} {}".format(kw.get("listen", ""), value)
488                continue
489
490            if param.startswith("no_"):
491                param = param[3:]
492                kw[param] = "false"
493            elif param in ("help", "call"):
494                kw[param] = True
495            elif cls._param_map[param] is asbool:
496                kw[param] = "true"
497            else:
498                kw[param] = value
499
500        return kw, args
501
502    @classmethod
503    def check_sockets(cls, sockets):
504        has_unix_socket = False
505        has_inet_socket = False
506        has_unsupported_socket = False
507        for sock in sockets:
508            if (
509                sock.family == socket.AF_INET or sock.family == socket.AF_INET6
510            ) and sock.type == socket.SOCK_STREAM:
511                has_inet_socket = True
512            elif (
513                hasattr(socket, "AF_UNIX")
514                and sock.family == socket.AF_UNIX
515                and sock.type == socket.SOCK_STREAM
516            ):
517                has_unix_socket = True
518            else:
519                has_unsupported_socket = True
520        if has_unix_socket and has_inet_socket:
521            raise ValueError("Internet and UNIX sockets may not be mixed.")
522        if has_unsupported_socket:
523            raise ValueError("Only Internet or UNIX stream sockets may be used.")
524