diff --git a/gunicorn/config.py b/gunicorn/config.py index 81086ab3..be92a058 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -2395,6 +2395,180 @@ class Ciphers(Setting): """ +# HTTP/2 Protocol Settings + +# Valid protocol identifiers +VALID_HTTP_PROTOCOLS = frozenset(["h1", "h2", "h3"]) +# Map protocol identifiers to ALPN protocol names +ALPN_PROTOCOL_MAP = { + "h1": "http/1.1", + "h2": "h2", + "h3": "h3", # Future: HTTP/3 over QUIC +} + + +def validate_http_protocols(val): + """Validate http_protocols setting. + + Accepts comma-separated list of protocol identifiers. + Valid values: h1 (HTTP/1.1), h2 (HTTP/2), h3 (HTTP/3 - future) + Order indicates preference (first = most preferred). + """ + if val is None: + return ["h1"] + if not isinstance(val, str): + raise TypeError("http_protocols must be a string") + + val = val.strip() + if not val: + return ["h1"] + + protocols = [p.strip().lower() for p in val.split(",") if p.strip()] + if not protocols: + return ["h1"] + + # Validate each protocol + for proto in protocols: + if proto not in VALID_HTTP_PROTOCOLS: + raise ValueError( + f"Invalid protocol '{proto}'. " + f"Valid protocols: {', '.join(sorted(VALID_HTTP_PROTOCOLS))}" + ) + + # Check for duplicates + if len(protocols) != len(set(protocols)): + raise ValueError("Duplicate protocols specified") + + return protocols + + +class HTTPProtocols(Setting): + name = "http_protocols" + section = "HTTP/2" + cli = ["--http-protocols"] + meta = "STRING" + validator = validate_http_protocols + default = "h1" + desc = """\ + HTTP protocol versions to support (comma-separated, order = preference). + + Valid protocols: + + * ``h1`` - HTTP/1.1 (default) + * ``h2`` - HTTP/2 (requires TLS with ALPN) + * ``h3`` - HTTP/3 (future, not yet implemented) + + Examples:: + + # HTTP/1.1 only (default, backward compatible) + --http-protocols=h1 + + # Prefer HTTP/2, fallback to HTTP/1.1 + --http-protocols=h2,h1 + + # HTTP/2 only (reject HTTP/1.1 clients) + --http-protocols=h2 + + HTTP/2 requires: + + * TLS (--certfile and --keyfile) + * The h2 library: ``pip install gunicorn[http2]`` + * ALPN-capable TLS client + + .. note:: + HTTP/2 cleartext (h2c) is not supported due to security concerns + and lack of browser support. + + .. versionadded:: 24.0.0 + """ + + +class HTTP2MaxConcurrentStreams(Setting): + name = "http2_max_concurrent_streams" + section = "HTTP/2" + cli = ["--http2-max-concurrent-streams"] + meta = "INT" + validator = validate_pos_int + type = int + default = 100 + desc = """\ + Maximum number of concurrent HTTP/2 streams per connection. + + This limits how many requests can be processed simultaneously on a + single HTTP/2 connection. Higher values allow more parallelism but + use more memory. + + Default is 100, which matches common server configurations. + The HTTP/2 specification allows up to 2^31-1. + + .. versionadded:: 24.0.0 + """ + + +class HTTP2InitialWindowSize(Setting): + name = "http2_initial_window_size" + section = "HTTP/2" + cli = ["--http2-initial-window-size"] + meta = "INT" + validator = validate_pos_int + type = int + default = 65535 + desc = """\ + Initial HTTP/2 flow control window size in bytes. + + This controls how much data can be in-flight before the receiver + sends WINDOW_UPDATE frames. Larger values can improve throughput + for large transfers but use more memory. + + Default is 65535 (64KB - 1), the HTTP/2 specification default. + Maximum is 2^31-1 (2147483647). + + .. versionadded:: 24.0.0 + """ + + +class HTTP2MaxFrameSize(Setting): + name = "http2_max_frame_size" + section = "HTTP/2" + cli = ["--http2-max-frame-size"] + meta = "INT" + validator = validate_pos_int + type = int + default = 16384 + desc = """\ + Maximum HTTP/2 frame payload size in bytes. + + This is the largest frame payload the server will accept. + Larger frames reduce framing overhead but may increase latency + for small messages. + + Default is 16384 (16KB), the HTTP/2 specification minimum. + Range is 16384 to 16777215 (16MB - 1). + + .. versionadded:: 24.0.0 + """ + + +class HTTP2MaxHeaderListSize(Setting): + name = "http2_max_header_list_size" + section = "HTTP/2" + cli = ["--http2-max-header-list-size"] + meta = "INT" + validator = validate_pos_int + type = int + default = 65536 + desc = """\ + Maximum size of HTTP/2 header list in bytes (HPACK protection). + + This limits the total size of headers after HPACK decompression. + Protects against compression bombs and excessive memory use. + + Default is 65536 (64KB). Set to 0 for unlimited (not recommended). + + .. versionadded:: 24.0.0 + """ + + class PasteGlobalConf(Setting): name = "raw_paste_global_conf" action = "append" diff --git a/gunicorn/http2/__init__.py b/gunicorn/http2/__init__.py new file mode 100644 index 00000000..40cb14c8 --- /dev/null +++ b/gunicorn/http2/__init__.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 - +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +""" +HTTP/2 support for Gunicorn. + +This module provides HTTP/2 protocol support using the hyper-h2 library. +HTTP/2 requires TLS with ALPN negotiation. +""" + +H2_MIN_VERSION = (4, 1, 0) + +_h2_available = None +_h2_version = None + + +def is_http2_available(): + """Check if HTTP/2 support is available. + + Returns: + bool: True if the h2 library is installed with minimum required version. + """ + global _h2_available, _h2_version + + if _h2_available is not None: + return _h2_available + + try: + import h2 + version_str = getattr(h2, '__version__', '0.0.0') + version_parts = tuple(int(x) for x in version_str.split('.')[:3]) + _h2_version = version_parts + _h2_available = version_parts >= H2_MIN_VERSION + except ImportError: + _h2_available = False + _h2_version = None + + return _h2_available + + +def get_h2_version(): + """Get the installed h2 library version. + + Returns: + tuple: Version tuple (major, minor, patch) or None if not installed. + """ + if _h2_version is None: + is_http2_available() # Populate _h2_version + return _h2_version + + +__all__ = [ + 'is_http2_available', + 'get_h2_version', + 'H2_MIN_VERSION', +] diff --git a/gunicorn/http2/errors.py b/gunicorn/http2/errors.py new file mode 100644 index 00000000..b5b08fc9 --- /dev/null +++ b/gunicorn/http2/errors.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 - +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +""" +HTTP/2 specific exceptions. + +These exceptions map to HTTP/2 error codes defined in RFC 7540. +""" + + +class HTTP2Error(Exception): + """Base exception for HTTP/2 errors.""" + + error_code = 0x0 # NO_ERROR + + def __init__(self, message=None, error_code=None): + self.message = message or self.__class__.__doc__ + if error_code is not None: + self.error_code = error_code + super().__init__(self.message) + + +class HTTP2ProtocolError(HTTP2Error): + """Protocol error detected.""" + + error_code = 0x1 # PROTOCOL_ERROR + + +class HTTP2InternalError(HTTP2Error): + """Internal error occurred.""" + + error_code = 0x2 # INTERNAL_ERROR + + +class HTTP2FlowControlError(HTTP2Error): + """Flow control limits exceeded.""" + + error_code = 0x3 # FLOW_CONTROL_ERROR + + +class HTTP2SettingsTimeout(HTTP2Error): + """Settings acknowledgment timeout.""" + + error_code = 0x4 # SETTINGS_TIMEOUT + + +class HTTP2StreamClosed(HTTP2Error): + """Stream was closed.""" + + error_code = 0x5 # STREAM_CLOSED + + +class HTTP2FrameSizeError(HTTP2Error): + """Frame size is incorrect.""" + + error_code = 0x6 # FRAME_SIZE_ERROR + + +class HTTP2RefusedStream(HTTP2Error): + """Stream was refused.""" + + error_code = 0x7 # REFUSED_STREAM + + +class HTTP2Cancel(HTTP2Error): + """Stream was cancelled.""" + + error_code = 0x8 # CANCEL + + +class HTTP2CompressionError(HTTP2Error): + """Compression state error.""" + + error_code = 0x9 # COMPRESSION_ERROR + + +class HTTP2ConnectError(HTTP2Error): + """Connection error during CONNECT.""" + + error_code = 0xa # CONNECT_ERROR + + +class HTTP2EnhanceYourCalm(HTTP2Error): + """Peer is generating excessive load.""" + + error_code = 0xb # ENHANCE_YOUR_CALM + + +class HTTP2InadequateSecurity(HTTP2Error): + """Transport security is inadequate.""" + + error_code = 0xc # INADEQUATE_SECURITY + + +class HTTP2RequiresHTTP11(HTTP2Error): + """HTTP/1.1 is required for this request.""" + + error_code = 0xd # HTTP_1_1_REQUIRED + + +class HTTP2StreamError(HTTP2Error): + """Error specific to a single stream.""" + + def __init__(self, stream_id, message=None, error_code=None): + self.stream_id = stream_id + super().__init__(message, error_code) + + def __str__(self): + return f"Stream {self.stream_id}: {self.message}" + + +class HTTP2ConnectionError(HTTP2Error): + """Error affecting the entire connection.""" + + pass + + +class HTTP2ConfigurationError(HTTP2Error): + """Invalid HTTP/2 configuration.""" + + pass + + +class HTTP2NotAvailable(HTTP2Error): + """HTTP/2 support is not available (h2 library not installed).""" + + def __init__(self, message=None): + message = message or "HTTP/2 requires the h2 library: pip install gunicorn[http2]" + super().__init__(message) + + +__all__ = [ + 'HTTP2Error', + 'HTTP2ProtocolError', + 'HTTP2InternalError', + 'HTTP2FlowControlError', + 'HTTP2SettingsTimeout', + 'HTTP2StreamClosed', + 'HTTP2FrameSizeError', + 'HTTP2RefusedStream', + 'HTTP2Cancel', + 'HTTP2CompressionError', + 'HTTP2ConnectError', + 'HTTP2EnhanceYourCalm', + 'HTTP2InadequateSecurity', + 'HTTP2RequiresHTTP11', + 'HTTP2StreamError', + 'HTTP2ConnectionError', + 'HTTP2ConfigurationError', + 'HTTP2NotAvailable', +] diff --git a/gunicorn/sock.py b/gunicorn/sock.py index d89d752c..f8b0615d 100644 --- a/gunicorn/sock.py +++ b/gunicorn/sock.py @@ -235,6 +235,35 @@ def close_sockets(listeners, unlink=True): os.unlink(sock_name) +def _get_alpn_protocols(conf): + """Get ALPN protocol list from configuration. + + Returns list of ALPN protocol identifiers based on http_protocols setting. + Returns empty list if HTTP/2 is not configured or available. + """ + from gunicorn.config import ALPN_PROTOCOL_MAP + + http_protocols = conf.http_protocols + if not http_protocols: + return [] + + # Only configure ALPN if h2 is in the protocol list + if "h2" not in http_protocols: + return [] + + # Check if h2 library is available + from gunicorn.http2 import is_http2_available + if not is_http2_available(): + return [] + + # Map to ALPN identifiers, maintaining preference order + alpn_protocols = [] + for proto in http_protocols: + if proto in ALPN_PROTOCOL_MAP: + alpn_protocols.append(ALPN_PROTOCOL_MAP[proto]) + return alpn_protocols + + def ssl_context(conf): def default_ssl_context_factory(): context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=conf.ca_certs) @@ -242,6 +271,12 @@ def ssl_context(conf): context.verify_mode = conf.cert_reqs if conf.ciphers: context.set_ciphers(conf.ciphers) + + # Configure ALPN for HTTP/2 if enabled + alpn_protocols = _get_alpn_protocols(conf) + if alpn_protocols: + context.set_alpn_protocols(alpn_protocols) + return context return conf.ssl_context(conf, default_ssl_context_factory) @@ -252,3 +287,29 @@ def ssl_wrap_socket(sock, conf): server_side=True, suppress_ragged_eofs=conf.suppress_ragged_eofs, do_handshake_on_connect=conf.do_handshake_on_connect) + + +def get_negotiated_protocol(ssl_socket): + """Get the negotiated ALPN protocol from an SSL socket. + + Returns: + str: The negotiated protocol name ('h2', 'http/1.1', etc.) + or None if no protocol was negotiated. + """ + if not isinstance(ssl_socket, ssl.SSLSocket): + return None + + try: + return ssl_socket.selected_alpn_protocol() + except (AttributeError, ssl.SSLError): + return None + + +def is_http2_negotiated(ssl_socket): + """Check if HTTP/2 was negotiated on an SSL socket. + + Returns: + bool: True if HTTP/2 was negotiated via ALPN. + """ + protocol = get_negotiated_protocol(ssl_socket) + return protocol == "h2" diff --git a/pyproject.toml b/pyproject.toml index af82d037..15900d9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,9 +52,11 @@ eventlet = ["eventlet>=0.40.3"] tornado = ["tornado>=6.5.0"] gthread = [] setproctitle = ["setproctitle"] +http2 = ["h2>=4.1.0"] testing = [ "gevent>=24.10.1", "eventlet>=0.40.3", + "h2>=4.1.0", "coverage", "pytest", "pytest-cov",