You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@httpd.apache.org by ic...@apache.org on 2021/11/30 16:30:26 UTC

svn commit: r1895433 [1/2] - in /httpd/httpd/trunk/test/modules/tls: ./ htdocs/ htdocs/a.mod-tls.test/ htdocs/b.mod-tls.test/ htdocs/b.mod-tls.test/dir1/

Author: icing
Date: Tue Nov 30 16:30:26 2021
New Revision: 1895433

URL: http://svn.apache.org/viewvc?rev=1895433&view=rev
Log:
  * test suite: adding modules/tls, the test suite for the
    new mod_tls module to be run via pytest.
    Integration into travis TBD.


Added:
    httpd/httpd/trunk/test/modules/tls/
    httpd/httpd/trunk/test/modules/tls/__init__.py
    httpd/httpd/trunk/test/modules/tls/conf.py
    httpd/httpd/trunk/test/modules/tls/conftest.py
    httpd/httpd/trunk/test/modules/tls/env.py
    httpd/httpd/trunk/test/modules/tls/htdocs/
    httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/
    httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/index.json
    httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/vars.py   (with props)
    httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/
    httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/
    httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/vars.py   (with props)
    httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/index.json
    httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/resp-jitter.py   (with props)
    httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/vars.py   (with props)
    httpd/httpd/trunk/test/modules/tls/htdocs/index.html
    httpd/httpd/trunk/test/modules/tls/htdocs/index.json
    httpd/httpd/trunk/test/modules/tls/load_test.py
    httpd/httpd/trunk/test/modules/tls/test_01_apache.py
    httpd/httpd/trunk/test/modules/tls/test_02_conf.py
    httpd/httpd/trunk/test/modules/tls/test_03_sni.py
    httpd/httpd/trunk/test/modules/tls/test_04_get.py
    httpd/httpd/trunk/test/modules/tls/test_05_proto.py
    httpd/httpd/trunk/test/modules/tls/test_06_ciphers.py
    httpd/httpd/trunk/test/modules/tls/test_07_alpn.py
    httpd/httpd/trunk/test/modules/tls/test_08_vars.py
    httpd/httpd/trunk/test/modules/tls/test_09_timeout.py
    httpd/httpd/trunk/test/modules/tls/test_10_session_id.py
    httpd/httpd/trunk/test/modules/tls/test_11_md.py
    httpd/httpd/trunk/test/modules/tls/test_12_cauth.py
    httpd/httpd/trunk/test/modules/tls/test_13_proxy.py
    httpd/httpd/trunk/test/modules/tls/test_14_proxy_ssl.py
    httpd/httpd/trunk/test/modules/tls/test_15_proxy_tls.py
    httpd/httpd/trunk/test/modules/tls/test_16_proxy_mixed.py
    httpd/httpd/trunk/test/modules/tls/test_17_proxy_machine_cert.py

Added: httpd/httpd/trunk/test/modules/tls/__init__.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/__init__.py?rev=1895433&view=auto
==============================================================================
    (empty)

Added: httpd/httpd/trunk/test/modules/tls/conf.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/conf.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/conf.py (added)
+++ httpd/httpd/trunk/test/modules/tls/conf.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,61 @@
+import os
+from typing import List, Dict, Any
+
+from pyhttpd.conf import  HttpdConf
+from pyhttpd.env import HttpdTestEnv
+
+
+class TlsTestConf(HttpdConf):
+
+    def __init__(self, env: HttpdTestEnv, extras: Dict[str, Any] = None):
+        extras = extras if extras is not None else {}
+        super().__init__(env=env, extras=extras)
+
+    def start_tls_vhost(self, domains: List[str], port=None, ssl_module=None):
+        if ssl_module is None:
+            ssl_module = 'mod_tls'
+        super().start_vhost(domains=domains, port=port, doc_root=f"htdocs/{domains[0]}", ssl_module=ssl_module)
+
+    def end_tls_vhost(self):
+        self.end_vhost()
+
+    def add_tls_vhosts(self, domains: List[str], port=None, ssl_module=None):
+        for domain in domains:
+            self.start_tls_vhost(domains=[domain], port=port, ssl_module=ssl_module)
+            self.end_tls_vhost()
+
+    def add_md_vhosts(self, domains: List[str], port = None):
+        self.add([
+            f"LoadModule md_module       {self.env.libexec_dir}/mod_md.so",
+            "LogLevel md:debug",
+        ])
+        for domain in domains:
+            self.add(f"<MDomain {domain}>")
+            for cred in self.env.ca.get_credentials_for_name(domain):
+                cert_file = os.path.relpath(cred.cert_file, self.env.server_dir)
+                pkey_file = os.path.relpath(cred.pkey_file, self.env.server_dir) if cred.pkey_file else cert_file
+                self.add([
+                    f"    MDCertificateFile {cert_file}",
+                    f"    MDCertificateKeyFile {pkey_file}",
+                    ])
+            self.add("</MDomain>")
+            super().add_vhost(domains=[domain], port=port, doc_root=f"htdocs/{domain}",
+                              with_ssl=True, with_certificates=False, ssl_module='mod_tls')
+
+    def add_md_base(self, domain: str):
+        self.add([
+            f"LoadModule md_module       {self.env.libexec_dir}/mod_md.so",
+            "LogLevel md:debug",
+            f"ServerName {domain}",
+            "MDBaseServer on",
+        ])
+        self.add(f"TLSEngine {self.env.https_port}")
+        self.add(f"<MDomain {domain}>")
+        for cred in self.env.ca.get_credentials_for_name(domain):
+            cert_file = os.path.relpath(cred.cert_file, self.env.server_dir)
+            pkey_file = os.path.relpath(cred.pkey_file, self.env.server_dir) if cred.pkey_file else cert_file
+            self.add([
+                f"MDCertificateFile {cert_file}",
+                f"MDCertificateKeyFile {pkey_file}",
+            ])
+        self.add("</MDomain>")

Added: httpd/httpd/trunk/test/modules/tls/conftest.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/conftest.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/conftest.py (added)
+++ httpd/httpd/trunk/test/modules/tls/conftest.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,39 @@
+import logging
+import os
+import sys
+import pytest
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+from .env import TlsTestEnv
+
+
+def pytest_report_header(config, startdir):
+    _x = config
+    _x = startdir
+    env = TlsTestEnv()
+    return "mod_tls [apache: {aversion}({prefix})]".format(
+        prefix=env.prefix,
+        aversion=env.get_httpd_version()
+    )
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> TlsTestEnv:
+    level = logging.INFO
+    console = logging.StreamHandler()
+    console.setLevel(level)
+    console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+    logging.getLogger('').addHandler(console)
+    logging.getLogger('').setLevel(level=level)
+    env = TlsTestEnv(pytestconfig=pytestconfig)
+    env.setup_httpd()
+    env.apache_access_log_clear()
+    env.httpd_error_log.clear_log()
+    return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _session_scope(env):
+    yield
+    assert env.apache_stop() == 0

Added: httpd/httpd/trunk/test/modules/tls/env.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/env.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/env.py (added)
+++ httpd/httpd/trunk/test/modules/tls/env.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,186 @@
+import inspect
+import logging
+import os
+import re
+import subprocess
+import sys
+import time
+
+from datetime import timedelta, datetime
+from http.client import HTTPConnection
+from typing import List, Optional, Dict, Tuple, Union
+from urllib.parse import urlparse
+
+from pyhttpd.certs import CertificateSpec
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+from pyhttpd.result import ExecResult
+
+log = logging.getLogger(__name__)
+
+
+class TlsTestSetup(HttpdTestSetup):
+
+    def __init__(self, env: 'HttpdTestEnv'):
+        super().__init__(env=env)
+        self.add_source_dir(os.path.dirname(inspect.getfile(TlsTestSetup)))
+        self.add_modules(["tls", "http2", "cgid", "watchdog", "proxy_http2"])
+
+
+class TlsCipher:
+
+    def __init__(self, id: int, name: str, flavour: str,
+                 min_version: float, max_version: float = None,
+                 openssl: str = None):
+        self.id = id
+        self.name = name
+        self.flavour = flavour
+        self.min_version = min_version
+        self.max_version = max_version if max_version is not None else self.min_version
+        if openssl is None:
+            if name.startswith('TLS13_'):
+                openssl = re.sub(r'^TLS13_', 'TLS_', name)
+            else:
+                openssl = re.sub(r'^TLS_', '', name)
+                openssl = re.sub(r'_WITH_([^_]+)_', r'_\1_', openssl)
+                openssl = re.sub(r'_AES_(\d+)', r'_AES\1', openssl)
+                openssl = re.sub(r'(_POLY1305)_\S+$', r'\1', openssl)
+                openssl = re.sub(r'_', '-', openssl)
+        self.openssl_name = openssl
+        self.id_name = "TLS_CIPHER_0x{0:04x}".format(self.id)
+
+    def __repr__(self):
+        return self.name
+
+    def __str__(self):
+        return self.name
+
+
+class TlsTestEnv(HttpdTestEnv):
+
+    # current rustls supported ciphers in their order of preference
+    # used to test cipher selection, see test_06_ciphers.py
+    RUSTLS_CIPHERS = [
+        TlsCipher(0x1303, "TLS13_CHACHA20_POLY1305_SHA256", "CHACHA", 1.3),
+        TlsCipher(0x1302, "TLS13_AES_256_GCM_SHA384", "AES", 1.3),
+        TlsCipher(0x1301, "TLS13_AES_128_GCM_SHA256", "AES", 1.3),
+        TlsCipher(0xcca9, "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", "ECDSA", 1.2),
+        TlsCipher(0xcca8, "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", "RSA", 1.2),
+        TlsCipher(0xc02c, "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", "ECDSA", 1.2),
+        TlsCipher(0xc02b, "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "ECDSA", 1.2),
+        TlsCipher(0xc030, "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "RSA", 1.2),
+        TlsCipher(0xc02f, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "RSA", 1.2),
+    ]
+
+    def __init__(self, pytestconfig=None):
+        super().__init__(pytestconfig=pytestconfig)
+        self._domain_a = "a.mod-tls.test"
+        self._domain_b = "b.mod-tls.test"
+        self.add_httpd_conf([
+            f'<Directory "{self.server_dir}/htdocs/{self.domain_a}">',
+            '    AllowOverride None',
+            '    Require all granted',
+            '    AddHandler cgi-script .py',
+            '    Options +ExecCGI',
+            '</Directory>',
+            f'<Directory "{self.server_dir}/htdocs/{self.domain_b}">',
+            '    AllowOverride None',
+            '    Require all granted',
+            '    AddHandler cgi-script .py',
+            '    Options +ExecCGI',
+            '</Directory>',
+            f'<VirtualHost *:{self.http_port}>',
+            '    ServerName localhost',
+            '    DocumentRoot "htdocs"',
+            '</VirtualHost>',
+            f'<VirtualHost *:{self.http_port}>',
+            f'    ServerName {self.domain_a}',
+            '    DocumentRoot "htdocs/a.mod-tls.test"',
+            '</VirtualHost>',
+            f'<VirtualHost *:{self.http_port}>',
+            f'    ServerName {self.domain_b}',
+            '    DocumentRoot "htdocs/b.mod-tls.test"',
+            '</VirtualHost>',
+        ])
+        self.add_cert_specs([
+            CertificateSpec(domains=[self.domain_a]),
+            CertificateSpec(domains=[self.domain_b], key_type='secp256r1', single_file=True),
+            CertificateSpec(domains=[self.domain_b], key_type='rsa4096'),
+            CertificateSpec(name="clientsX", sub_specs=[
+                CertificateSpec(name="user1", client=True, single_file=True),
+                CertificateSpec(name="user2", client=True, single_file=True),
+                CertificateSpec(name="user_expired", client=True,
+                                single_file=True, valid_from=timedelta(days=-91),
+                                valid_to=timedelta(days=-1)),
+            ]),
+            CertificateSpec(name="clientsY", sub_specs=[
+                CertificateSpec(name="user1", client=True, single_file=True),
+            ]),
+            CertificateSpec(name="user1", client=True, single_file=True),
+        ])
+        self.add_httpd_log_modules(['tls'])
+
+
+    def setup_httpd(self, setup: TlsTestSetup = None):
+        if setup is None:
+            setup = TlsTestSetup(env=self)
+        super().setup_httpd(setup=setup)
+
+    @property
+    def domain_a(self) -> str:
+        return self._domain_a
+
+    @property
+    def domain_b(self) -> str:
+        return self._domain_b
+
+    def tls_get(self, domain, paths: Union[str, List[str]], options: List[str] = None) -> ExecResult:
+        if isinstance(paths, str):
+            paths = [paths]
+        urls = [f"https://{domain}:{self.https_port}{path}" for path in paths]
+        return self.curl_raw(urls=urls, options=options)
+
+    def tls_get_json(self, domain: str, path: str, options=None):
+        r = self.tls_get(domain=domain, paths=path, options=options)
+        return r.json
+
+    def run_diff(self, fleft: str, fright: str) -> ExecResult:
+        return self.run(['diff', '-u', fleft, fright])
+
+    def openssl(self, args: List[str]) -> ExecResult:
+        return self.run(['openssl'] + args)
+
+    def openssl_client(self, domain, extra_args: List[str] = None) -> ExecResult:
+        args = ["s_client", "-CAfile", self.ca.cert_file, "-servername", domain,
+                "-connect", "localhost:{port}".format(
+                    port=self.https_port
+                )]
+        if extra_args:
+            args.extend(extra_args)
+        args.extend([])
+        return self.openssl(args)
+
+    CURL_SUPPORTS_TLS_1_3 = None
+
+    def curl_supports_tls_1_3(self) -> bool:
+        if self.CURL_SUPPORTS_TLS_1_3 is None:
+            r = self.tls_get(self.domain_a, "/index.json", options=["--tlsv1.3"])
+            self.CURL_SUPPORTS_TLS_1_3 = r.exit_code == 0
+        return self.CURL_SUPPORTS_TLS_1_3
+
+    OPENSSL_SUPPORTED_PROTOCOLS = None
+
+    @staticmethod
+    def openssl_supports_tls_1_3() -> bool:
+        if TlsTestEnv.OPENSSL_SUPPORTED_PROTOCOLS is None:
+            env = TlsTestEnv()
+            r = env.openssl(args=["ciphers", "-v"])
+            protos = set()
+            ciphers = set()
+            for line in r.stdout.splitlines():
+                m = re.match(r'^(\S+)\s+(\S+)\s+(.*)$', line)
+                if m:
+                    ciphers.add(m.group(1))
+                    protos.add(m.group(2))
+            TlsTestEnv.OPENSSL_SUPPORTED_PROTOCOLS = protos
+            TlsTestEnv.OPENSSL_SUPPORTED_CIPHERS = ciphers
+        return "TLSv1.3" in TlsTestEnv.OPENSSL_SUPPORTED_PROTOCOLS

Added: httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/index.json
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/index.json?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/index.json (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/index.json Tue Nov 30 16:30:26 2021
@@ -0,0 +1,3 @@
+{
+  "domain": "a.mod-tls.test"
+}
\ No newline at end of file

Added: httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/vars.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/vars.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/vars.py (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/vars.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+import json
+import os, cgi
+import re
+
+jenc = json.JSONEncoder()
+
+def get_var(name: str, def_val: str = ""):
+    if name in os.environ:
+        return os.environ[name]
+    return def_val
+
+def get_json_var(name: str, def_val: str = ""):
+    var = get_var(name, def_val=def_val)
+    return jenc.encode(var)
+
+
+name = None
+try:
+    form = cgi.FieldStorage()
+    if 'name' in form:
+        name = str(form['name'].value)
+except Exception:
+    pass
+
+print("Content-Type: application/json\n")
+if name:
+    print(f"""{{ "{name}" : {get_json_var(name, '')}}}""")
+else:
+    print(f"""{{ "https" : {get_json_var('HTTPS', '')},
+  "host" : {get_json_var('SERVER_NAME', '')},
+  "protocol" : {get_json_var('SERVER_PROTOCOL', '')},
+  "ssl_protocol" : {get_json_var('SSL_PROTOCOL', '')},
+  "ssl_cipher" : {get_json_var('SSL_CIPHER', '')}
+}}""")
+

Propchange: httpd/httpd/trunk/test/modules/tls/htdocs/a.mod-tls.test/vars.py
------------------------------------------------------------------------------
    svn:executable = *

Added: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/vars.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/vars.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/vars.py (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/vars.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+import os
+
+def get_var(name: str, def_val: str = ""):
+    if name in os.environ:
+        return os.environ[name]
+    return def_val
+
+print("Content-Type: application/json")
+print()
+print("""{{ "https" : "{https}",
+  "host" : "{server_name}",
+  "protocol" : "{protocol}",
+  "ssl_protocol" : "{ssl_protocol}",
+  "ssl_cipher" : "{ssl_cipher}"
+}}""".format(
+    https=get_var('HTTPS', ''),
+    server_name=get_var('SERVER_NAME', ''),
+    protocol=get_var('SERVER_PROTOCOL', ''),
+    ssl_protocol=get_var('SSL_PROTOCOL', ''),
+    ssl_cipher=get_var('SSL_CIPHER', ''),
+))
+

Propchange: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/dir1/vars.py
------------------------------------------------------------------------------
    svn:executable = *

Added: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/index.json
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/index.json?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/index.json (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/index.json Tue Nov 30 16:30:26 2021
@@ -0,0 +1,3 @@
+{
+  "domain": "b.mod-tls.test"
+}
\ No newline at end of file

Added: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/resp-jitter.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/resp-jitter.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/resp-jitter.py (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/resp-jitter.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+import random
+import sys
+import time
+from datetime import timedelta
+
+random.seed()
+to_write = total_len = random.randint(1, 10*1024*1024)
+
+sys.stdout.write("Content-Type: application/octet-stream\n")
+sys.stdout.write(f"Content-Length: {total_len}\n")
+sys.stdout.write("\n")
+sys.stdout.flush()
+
+while to_write > 0:
+    len = random.randint(1, 1024*1024)
+    len = min(len, to_write)
+    sys.stdout.buffer.write(random.randbytes(len))
+    to_write -= len
+    delay = timedelta(seconds=random.uniform(0.0, 0.5))
+    time.sleep(delay.total_seconds())
+sys.stdout.flush()
+

Propchange: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/resp-jitter.py
------------------------------------------------------------------------------
    svn:executable = *

Added: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/vars.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/vars.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/vars.py (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/vars.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+import json
+import os, cgi
+
+jenc = json.JSONEncoder()
+
+def get_var(name: str, def_val: str = ""):
+    if name in os.environ:
+        return os.environ[name]
+    return def_val
+
+def get_json_var(name: str, def_val: str = ""):
+    var = get_var(name, def_val=def_val)
+    return jenc.encode(var)
+
+
+name = None
+try:
+    form = cgi.FieldStorage()
+    if 'name' in form:
+        name = str(form['name'].value)
+except Exception:
+    pass
+
+print("Content-Type: application/json\n")
+if name:
+    print(f"""{{ "{name}" : {get_json_var(name, '')}}}""")
+else:
+    print(f"""{{ "https" : {get_json_var('HTTPS', '')},
+  "host" : {get_json_var('SERVER_NAME', '')},
+  "protocol" : {get_json_var('SERVER_PROTOCOL', '')},
+  "ssl_protocol" : {get_json_var('SSL_PROTOCOL', '')},
+  "ssl_cipher" : {get_json_var('SSL_CIPHER', '')}
+}}""")
+

Propchange: httpd/httpd/trunk/test/modules/tls/htdocs/b.mod-tls.test/vars.py
------------------------------------------------------------------------------
    svn:executable = *

Added: httpd/httpd/trunk/test/modules/tls/htdocs/index.html
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/index.html?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/index.html (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/index.html Tue Nov 30 16:30:26 2021
@@ -0,0 +1,9 @@
+<html>
+    <head>
+        <title>mod_h2 test site generic</title>
+    </head>
+    <body>
+        <h1>mod_h2 test site generic</h1>
+    </body>
+</html>
+

Added: httpd/httpd/trunk/test/modules/tls/htdocs/index.json
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/htdocs/index.json?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/htdocs/index.json (added)
+++ httpd/httpd/trunk/test/modules/tls/htdocs/index.json Tue Nov 30 16:30:26 2021
@@ -0,0 +1,3 @@
+{
+  "domain": "localhost"
+}
\ No newline at end of file

Added: httpd/httpd/trunk/test/modules/tls/load_test.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/load_test.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/load_test.py (added)
+++ httpd/httpd/trunk/test/modules/tls/load_test.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,882 @@
+import argparse
+import logging
+import multiprocessing
+import os
+import re
+import sys
+import time
+from datetime import timedelta, datetime
+from threading import Thread
+from tqdm import tqdm  # type: ignore
+from typing import Dict, Iterable, List, Tuple, Optional
+
+sys.path.append(os.path.dirname(__file__))
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+from conf import TlsTestConf
+from env import TlsTestEnv, TlsTestSetup
+from pyhttpd.result import ExecResult
+
+log = logging.getLogger(__name__)
+
+
+class LoadTestException(Exception):
+    pass
+
+
+class H2LoadLogSummary:
+
+    @staticmethod
+    def from_file(fpath: str, title: str, duration: timedelta) -> 'H2LoadLogSummary':
+        with open(fpath) as fd:
+            return H2LoadLogSummary.from_lines(fd.readlines(), title=title, duration=duration)
+
+    @staticmethod
+    def from_lines(lines: Iterable[str], title: str, duration: timedelta) -> 'H2LoadLogSummary':
+        stati = {}
+        count = 0
+        all_durations = timedelta(milliseconds=0)
+        for line in lines:
+            parts = re.split(r'\s+', line)  # start(us), status(int), duration(ms), tbd.
+            if len(parts) >= 3 and parts[0] and parts[1] and parts[2]:
+                count += 1
+                status = int(parts[1])
+                if status in stati:
+                    stati[status] += 1
+                else:
+                    stati[status] = 1
+                all_durations += timedelta(microseconds=int(parts[2]))
+            else:
+                sys.stderr.write("unrecognize log line: {0}".format(line))
+        return H2LoadLogSummary(title=title, total=count, stati=stati,
+                                duration=duration, all_durations=all_durations)
+
+    def __init__(self, title: str, total: int, stati: Dict[int, int],
+                 duration: timedelta, all_durations: timedelta):
+        self._title = title
+        self._total = total
+        self._stati = stati
+        self._duration = duration
+        self._all_durations = all_durations
+        self._transfered_mb = 0.0
+        self._exec_result = None
+        self._expected_responses = 0
+
+    @property
+    def title(self) -> str:
+        return self._title
+
+    @property
+    def response_count(self) -> int:
+        return self._total
+
+    @property
+    def duration(self) -> timedelta:
+        return self._duration
+
+    @property
+    def response_durations(self) -> timedelta:
+        return self._all_durations
+
+    @property
+    def response_stati(self) -> Dict[int, int]:
+        return self._stati
+
+    @property
+    def expected_responses(self) -> int:
+        return self._expected_responses
+
+    @property
+    def execution(self) -> ExecResult:
+        return self._exec_result
+
+    def all_200(self) -> bool:
+        non_200s = [n for n in self._stati.keys() if n != 200]
+        return len(non_200s) == 0
+
+    @property
+    def throughput_mb(self) -> float:
+        if self._transfered_mb > 0.0:
+            return self._transfered_mb / self.duration.total_seconds()
+        return 0.0
+
+    def set_transfered_mb(self, mb: float) -> None:
+        self._transfered_mb = mb
+
+    def set_exec_result(self, result: ExecResult):
+        self._exec_result = result
+
+    def set_expected_responses(self, n: int):
+        self._expected_responses = n
+
+    def get_footnote(self) -> Optional[str]:
+        note = ""
+        if 0 < self.expected_responses != self.response_count:
+            note += "{0}/{1} missing".format(
+                self.expected_responses - self.response_count,
+                self.expected_responses
+            )
+        if not self.all_200():
+            note += ", non 200s:"
+            for status in [n for n in self.response_stati.keys() if n != 200]:
+                note += " {0}={1}".format(status, self.response_stati[status])
+        return note if len(note) else None
+
+
+class H2LoadMonitor:
+
+    def __init__(self, fpath: str, expected: int, title: str):
+        self._fpath = fpath
+        self._expected = expected
+        self._title = title
+        self._tqdm = tqdm(desc=title, total=expected, unit="request", leave=False)
+        self._running = False
+        self._lines = ()
+        self._tail = None
+
+    def start(self):
+        self._tail = Thread(target=self._collect, kwargs={'self': self})
+        self._running = True
+        self._tail.start()
+
+    def get_summary(self, duration: timedelta) -> H2LoadLogSummary:
+        self._running = False
+        self._tail.join()
+        return H2LoadLogSummary.from_file(self._fpath, title=self._title, duration=duration)
+
+    def stop(self):
+        self._running = False
+
+    @staticmethod
+    def _collect(self) -> None:
+        first_call = True
+        while self._running:
+            try:
+                with open(self._fpath) as fd:
+                    if first_call:
+                        fd.seek(0, 2)
+                        first_call = False
+                    latest_data = fd.read()
+                    while self._running:
+                        if '\n' not in latest_data:
+                            latest_data += fd.read()
+                            if '\n' not in latest_data:
+                                if not os.path.isfile(self._fpath):
+                                    break
+                                time.sleep(0.1)
+                                continue
+                        lines = latest_data.split('\n')
+                        if lines[-1] != '\n':
+                            latest_data = lines[-1]
+                            lines = lines[:-1]
+                        else:
+                            latest_data = None
+                        self._tqdm.update(n=len(lines))
+                        if latest_data is None:
+                            latest_data = fd.read()
+            except IOError:
+                time.sleep(0.1)
+        self._tqdm.close()
+
+
+def mk_text_file(fpath: str, lines: int):
+    t110 = ""
+    for _ in range(11):
+        t110 += "0123456789"
+    with open(fpath, "w") as fd:
+        for i in range(lines):
+            fd.write("{0:015d}: ".format(i))  # total 128 bytes per line
+            fd.write(t110)
+            fd.write("\n")
+
+
+class LoadTestCase:
+
+    @staticmethod
+    def from_scenario(scenario: Dict, env: TlsTestEnv) -> 'SingleFileLoadTest':
+        raise NotImplemented
+
+    def run(self) -> H2LoadLogSummary:
+        raise NotImplemented
+
+    def format_result(self, summary: H2LoadLogSummary) -> str:
+        raise NotImplemented
+
+    @staticmethod
+    def setup_base_conf(env: TlsTestEnv, worker_count: int = 5000, extras=None) -> TlsTestConf:
+        conf = TlsTestConf(env=env, extras=extras)
+        # ylavic's formula
+        process_count = int(max(10, min(100, int(worker_count / 100))))
+        thread_count = int(max(25, int(worker_count / process_count)))
+        conf.add(f"""
+        StartServers             1
+        ServerLimit              {int(process_count * 2.5)}
+        ThreadLimit              {thread_count}
+        ThreadsPerChild          {thread_count}
+        MinSpareThreads          {thread_count}
+        MaxSpareThreads          {int(worker_count / 2)}
+        MaxRequestWorkers        {worker_count}
+        MaxConnectionsPerChild   0
+        KeepAliveTimeout         60
+        MaxKeepAliveRequests     0
+        """)
+        return conf
+
+    @staticmethod
+    def start_server(env: TlsTestEnv, cd: timedelta = None):
+        if cd:
+            with tqdm(desc="connection cooldown", total=int(cd.total_seconds()), unit="s", leave=False) as t:
+                end = datetime.now() + cd
+                while datetime.now() < end:
+                    time.sleep(1)
+                    t.update()
+        assert env.apache_restart() == 0
+
+    @staticmethod
+    def server_setup(env: TlsTestEnv, ssl_module: str):
+        if 'mod_tls' == ssl_module:
+            extras = {
+                'base': [
+                    "Protocols h2 http/1.1",
+                    "ProxyPreserveHost on",
+                    f"TLSProxyCA {env.ca.cert_file}",
+                    f"<Proxy https://127.0.0.1:{env.https_port}/>",
+                    "    TLSProxyEngine on",
+                    "</Proxy>",
+                    f"<Proxy h2://127.0.0.1:{env.https_port}/>",
+                    "    TLSProxyEngine on",
+                    "</Proxy>",
+                ],
+                env.domain_a: [
+                    f"ProxyPass /proxy-h1/ https://127.0.0.1:{env.https_port}/",
+                    f"ProxyPass /proxy-h2/ h2://127.0.0.1:{env.https_port}/",
+                    f"TLSOptions +StdEnvVars",
+                ],
+            }
+        elif 'mod_ssl' == ssl_module:
+            extras = {
+                'base': [
+                    "Protocols h2 http/1.1",
+                    "ProxyPreserveHost on",
+                    "SSLProxyVerify require",
+                    f"SSLProxyCACertificateFile {env.ca.cert_file}",
+                    f"<Proxy https://127.0.0.1:{env.https_port}/>",
+                    "    SSLProxyEngine on",
+                    "</Proxy>",
+                    f"<Proxy h2://127.0.0.1:{env.https_port}/>",
+                    "    SSLProxyEngine on",
+                    "</Proxy>",
+                ],
+                env.domain_a: [
+                    f"ProxyPass /proxy-h1/ https://127.0.0.1:{env.https_port}/",
+                    f"ProxyPass /proxy-h2/ h2://127.0.0.1:{env.https_port}/",
+                    "TLSOptions +StdEnvVars",
+                ],
+            }
+        elif 'mod_gnutls' == ssl_module:
+            extras = {
+                'base': [
+                    "Protocols h2 http/1.1",
+                    "ProxyPreserveHost on",
+                    f"GnuTLSProxyCAFile {env.ca.cert_file}",
+                    f"<Proxy https://127.0.0.1:{env.https_port}/>",
+                    "    GnuTLSProxyEngine on",
+                    "</Proxy>",
+                    f"<Proxy h2://127.0.0.1:{env.https_port}/>",
+                    "    GnuTLSProxyEngine on",
+                    "</Proxy>",
+                ],
+                env.domain_a: [
+                    f"ProxyPass /proxy-h1/ https://127.0.0.1:{env.https_port}/",
+                    f"ProxyPass /proxy-h2/ h2://127.0.0.1:{env.https_port}/",
+                ],
+            }
+        else:
+            raise LoadTestException("tests for module: {0}".format(ssl_module))
+        conf = LoadTestCase.setup_base_conf(env=env, extras=extras)
+        conf.add_tls_vhosts(domains=[env.domain_a], ssl_module=ssl_module)
+        conf.install()
+
+
+class SingleFileLoadTest(LoadTestCase):
+
+    def __init__(self, env: TlsTestEnv, location: str,
+                 clients: int, requests: int, resource_kb: int,
+                 ssl_module: str = 'mod_tls', protocol: str = 'h2',
+                 threads: int = None):
+        self.env = env
+        self._location = location
+        self._clients = clients
+        self._requests = requests
+        self._resource_kb = resource_kb
+        self._ssl_module = ssl_module
+        self._protocol = protocol
+        self._threads = threads if threads is not None else min(multiprocessing.cpu_count() / 2, self._clients)
+
+    @staticmethod
+    def from_scenario(scenario: Dict, env: TlsTestEnv) -> 'SingleFileLoadTest':
+        return SingleFileLoadTest(
+            env=env,
+            location=scenario['location'],
+            clients=scenario['clients'], requests=scenario['requests'],
+            ssl_module=scenario['module'], resource_kb=scenario['rsize'],
+            protocol=scenario['protocol'] if 'protocol' in scenario else 'h2'
+        )
+
+    def _setup(self) -> str:
+        LoadTestCase.server_setup(env=self.env, ssl_module=self._ssl_module)
+        docs_a = os.path.join(self.env.server_docs_dir, self.env.domain_a)
+        fname = "{0}k.txt".format(self._resource_kb)
+        mk_text_file(os.path.join(docs_a, fname), 8 * self._resource_kb)
+        self.start_server(env=self.env)
+        return fname
+
+    def _teardown(self):
+        pass
+
+    def run_test(self, mode: str, path: str) -> H2LoadLogSummary:
+        monitor = None
+        try:
+            log_file = "{gen_dir}/h2load.log".format(gen_dir=self.env.gen_dir)
+            if os.path.isfile(log_file):
+                os.remove(log_file)
+            monitor = H2LoadMonitor(log_file, expected=self._requests,
+                                    title=f"{self._ssl_module}/{self._protocol}/"
+                                          f"{self._clients}c/{self._resource_kb / 1024}MB[{mode}]")
+            monitor.start()
+            args = [
+                'h2load',
+                '--clients={0}'.format(self._clients),
+                '--threads={0}'.format(self._threads),
+                '--requests={0}'.format(self._requests),
+                '--log-file={0}'.format(log_file),
+                '--connect-to=localhost:{0}'.format(self.env.https_port)
+            ]
+            if self._protocol == 'h1' or self._protocol == 'http/1.1':
+                args.append('--h1')
+            elif self._protocol == 'h2':
+                args.extend(['-m', "6"])
+            else:
+                raise Exception(f"unknown protocol: {self._protocol}")
+            r = self.env.run(args + [
+                f'https://{self.env.domain_a}:{self.env.https_port}{self._location}{path}'
+            ])
+            if r.exit_code != 0:
+                raise LoadTestException("h2load returned {0}: {1}".format(r.exit_code, r.stderr))
+            summary = monitor.get_summary(duration=r.duration)
+            summary.set_expected_responses(self._requests)
+            summary.set_exec_result(r)
+            summary.set_transfered_mb(self._requests * self._resource_kb / 1024)
+            return summary
+        finally:
+            if monitor is not None:
+                monitor.stop()
+
+    def run(self) -> H2LoadLogSummary:
+        path = self._setup()
+        try:
+            self.run_test(mode="warmup", path=path)
+            return self.run_test(mode="measure", path=path)
+        finally:
+            self._teardown()
+
+    def format_result(self, summary: H2LoadLogSummary) -> Tuple[str, Optional[List[str]]]:
+        return "{0:.0f}".format(summary.throughput_mb), summary.get_footnote()
+
+
+class MultiFileLoadTest(LoadTestCase):
+    SETUP_DONE = False
+
+    def __init__(self, env: TlsTestEnv, location: str,
+                 clients: int, requests: int, file_count: int,
+                 file_sizes: List[int],
+                 ssl_module: str = 'mod_tls', protocol: str = 'h2',
+                 threads: int = None, ):
+        self.env = env
+        self._location = location
+        self._clients = clients
+        self._requests = requests
+        self._file_count = file_count
+        self._file_sizes = file_sizes
+        self._ssl_module = ssl_module
+        self._protocol = protocol
+        self._threads = threads if threads is not None else \
+            min(multiprocessing.cpu_count() / 2, self._clients)
+        self._url_file = "{gen_dir}/h2load-urls.txt".format(gen_dir=self.env.gen_dir)
+
+    @staticmethod
+    def from_scenario(scenario: Dict, env: TlsTestEnv) -> 'MultiFileLoadTest':
+        return MultiFileLoadTest(
+            env=env,
+            location=scenario['location'],
+            clients=scenario['clients'], requests=scenario['requests'],
+            file_sizes=scenario['file_sizes'], file_count=scenario['file_count'],
+            ssl_module=scenario['module'], protocol=scenario['protocol']
+        )
+
+    def _setup(self, cls):
+        LoadTestCase.server_setup(env=self.env, ssl_module=self._ssl_module)
+        if not cls.SETUP_DONE:
+            with tqdm(desc="setup resources", total=self._file_count, unit="file", leave=False) as t:
+                docs_a = os.path.join(self.env.server_docs_dir, self.env.domain_a)
+                uris = []
+                for i in range(self._file_count):
+                    fsize = self._file_sizes[i % len(self._file_sizes)]
+                    if fsize is None:
+                        raise Exception("file sizes?: {0} {1}".format(i, fsize))
+                    fname = "{0}-{1}k.txt".format(i, fsize)
+                    mk_text_file(os.path.join(docs_a, fname), 8 * fsize)
+                    uris.append(f"{self._location}{fname}")
+                    t.update()
+                with open(self._url_file, 'w') as fd:
+                    fd.write("\n".join(uris))
+                    fd.write("\n")
+            cls.SETUP_DONE = True
+        self.start_server(env=self.env)
+
+    def _teardown(self):
+        pass
+
+    def run_test(self, mode: str, path: str) -> H2LoadLogSummary:
+        _path = path
+        monitor = None
+        try:
+            log_file = "{gen_dir}/h2load.log".format(gen_dir=self.env.gen_dir)
+            if os.path.isfile(log_file):
+                os.remove(log_file)
+            monitor = H2LoadMonitor(log_file, expected=self._requests,
+                                    title=f"{self._ssl_module}/{self._protocol}/"
+                                          f"{self._file_count / 1024}f/{self._clients}c[{mode}]")
+            monitor.start()
+            args = [
+                'h2load',
+                '--clients={0}'.format(self._clients),
+                '--requests={0}'.format(self._requests),
+                '--input-file={0}'.format(self._url_file),
+                '--log-file={0}'.format(log_file),
+                '--connect-to=localhost:{0}'.format(self.env.https_port)
+            ]
+            if self._protocol == 'h1' or self._protocol == 'http/1.1':
+                args.append('--h1')
+            elif self._protocol == 'h2':
+                args.extend(['-m', "6"])
+            else:
+                raise Exception(f"unknown protocol: {self._protocol}")
+            r = self.env.run(args + [
+                f'--base-uri=https://{self.env.domain_a}:{self.env.https_port}{self._location}'
+            ])
+            if r.exit_code != 0:
+                raise LoadTestException("h2load returned {0}: {1}".format(r.exit_code, r.stderr))
+            summary = monitor.get_summary(duration=r.duration)
+            summary.set_expected_responses(self._requests)
+            summary.set_exec_result(r)
+            return summary
+        finally:
+            if monitor is not None:
+                monitor.stop()
+
+    def run(self) -> H2LoadLogSummary:
+        path = self._setup(self.__class__)
+        try:
+            time.sleep(1)
+            self.run_test(mode="warmup", path=path)
+            return self.run_test(mode="measure", path=path)
+        finally:
+            self._teardown()
+
+    def format_result(self, summary: H2LoadLogSummary) -> Tuple[str, Optional[List[str]]]:
+        return "{0:.0f}".format(
+            summary.response_count / summary.duration.total_seconds()
+        ), summary.get_footnote()
+
+
+class ConnectionLoadTest(LoadTestCase):
+    SETUP_DONE = False
+
+    def __init__(self, env: TlsTestEnv, location: str,
+                 clients: int, requests: int, duration: timedelta,
+                 file_count: int, file_sizes: List[int], cooldown: timedelta,
+                 ssl_module: str = 'mod_tls', protocol: str = 'h2'):
+        self.env = env
+        self._location = location
+        self._clients = clients
+        self._requests = requests
+        self._duration = duration
+        self._file_count = file_count
+        self._file_sizes = file_sizes
+        self._ssl_module = ssl_module
+        self._protocol = protocol
+        self._url_file = "{gen_dir}/h2load-urls.txt".format(gen_dir=self.env.gen_dir)
+        self._cd = cooldown
+
+    @staticmethod
+    def from_scenario(scenario: Dict, env: TlsTestEnv) -> 'ConnectionLoadTest':
+        return ConnectionLoadTest(
+            env=env,
+            location=scenario['location'],
+            clients=scenario['clients'], requests=scenario['requests'],
+            duration=scenario['duration'], cooldown=scenario['cooldown'],
+            file_sizes=scenario['file_sizes'], file_count=scenario['file_count'],
+            ssl_module=scenario['module'], protocol=scenario['protocol']
+        )
+
+    def _setup(self):
+        LoadTestCase.server_setup(env=self.env, ssl_module=self._ssl_module)
+        if not ConnectionLoadTest.SETUP_DONE:
+            with tqdm(desc="setup resources", total=self._file_count, unit="file", leave=False) as t:
+                docs_a = os.path.join(self.env.server_docs_dir, self.env.domain_a)
+                uris = []
+                for i in range(self._file_count):
+                    fsize = self._file_sizes[i % len(self._file_sizes)]
+                    if fsize is None:
+                        raise Exception("file sizes?: {0} {1}".format(i, fsize))
+                    fname = "{0}-{1}k.txt".format(i, fsize)
+                    mk_text_file(os.path.join(docs_a, fname), 8 * fsize)
+                    uris.append(f"{self._location}{fname}")
+                    t.update()
+                with open(self._url_file, 'w') as fd:
+                    fd.write("\n".join(uris))
+                    fd.write("\n")
+            ConnectionLoadTest.SETUP_DONE = True
+        self.start_server(env=self.env, cd=self._cd)
+
+    def _teardown(self):
+        pass
+
+    def run_test(self, mode: str, path: str) -> H2LoadLogSummary:
+        _mode = mode
+        _path = path
+        monitor = None
+        try:
+            log_file = "{gen_dir}/h2load.log".format(gen_dir=self.env.gen_dir)
+            if os.path.isfile(log_file):
+                os.remove(log_file)
+            monitor = H2LoadMonitor(log_file, expected=0,
+                                    title=f"{self._ssl_module}/{self._protocol}/"
+                                          f"{self._clients}c/{self._duration.total_seconds()}s")
+            monitor.start()
+            args = [
+                'h2load',
+                '--clients={0}'.format(self._clients),
+                '--requests={0}'.format(self._requests * self._clients),
+                '--input-file={0}'.format(self._url_file),
+                '--log-file={0}'.format(log_file),
+                '--connect-to=localhost:{0}'.format(self.env.https_port)
+            ]
+            if self._protocol == 'h1' or self._protocol == 'http/1.1':
+                args.append('--h1')
+            elif self._protocol == 'h2':
+                args.extend(['-m', "6"])
+            else:
+                raise Exception(f"unknown protocol: {self._protocol}")
+            args += [
+                f'--base-uri=https://{self.env.domain_a}:{self.env.https_port}{self._location}'
+            ]
+            end = datetime.now() + self._duration
+            r = None
+            while datetime.now() < end:
+                r = self.env.run(args)
+                if r.exit_code != 0:
+                    raise LoadTestException("h2load returned {0}: {1}".format(r.exit_code, r.stderr))
+            summary = monitor.get_summary(duration=self._duration)
+            summary.set_exec_result(r)
+            return summary
+        finally:
+            if monitor is not None:
+                monitor.stop()
+
+    def run(self) -> H2LoadLogSummary:
+        path = self._setup()
+        try:
+            return self.run_test(mode="measure", path=path)
+        finally:
+            self._teardown()
+
+    def format_result(self, summary: H2LoadLogSummary) -> Tuple[str, Optional[List[str]]]:
+        return "{0:.0f}".format(
+            summary.response_count / summary.duration.total_seconds() / self._requests
+        ), summary.get_footnote()
+
+
+class LoadTest:
+
+    @staticmethod
+    def print_table(table: List[List[str]], foot_notes: List[str] = None):
+        col_widths = []
+        col_sep = "   "
+        for row in table[1:]:
+            for idx, cell in enumerate(row):
+                if idx >= len(col_widths):
+                    col_widths.append(len(cell))
+                else:
+                    col_widths[idx] = max(len(cell), col_widths[idx])
+        row_len = sum(col_widths) + (len(col_widths) * len(col_sep))
+        print(f"{' '.join(table[0]):^{row_len}}")
+        for row in table[1:]:
+            line = ""
+            for idx, cell in enumerate(row):
+                line += f"{col_sep if idx > 0 else ''}{cell:>{col_widths[idx]}}"
+            print(line)
+        if foot_notes is not None:
+            for idx, note in enumerate(foot_notes):
+                print("{0:3d}) {1}".format(idx + 1, note))
+
+    @staticmethod
+    def scenario_with(base: Dict, updates: Dict) -> Dict:
+        scenario = base.copy()
+        scenario.update(updates)
+        return scenario
+
+    @classmethod
+    def main(cls):
+        parser = argparse.ArgumentParser(prog='load_h1', description="""
+            Run a range of load tests against the test Apache setup.
+            """)
+        parser.add_argument("-m", "--module", type=str, default=None,
+                            help="which module to test, defaults to all")
+        parser.add_argument("-p", "--protocol", type=str, default=None,
+                            help="which protocols to test, defaults to all")
+        parser.add_argument("-v", "--verbose", action='count', default=0,
+                            help="log more output on stderr")
+        parser.add_argument("names", nargs='*', help="Name(s) of scenarios to run")
+        args = parser.parse_args()
+
+        if args.verbose > 0:
+            console = logging.StreamHandler()
+            console.setLevel(logging.INFO)
+            console.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
+            logging.getLogger('').addHandler(console)
+
+        rv = 0
+        env = TlsTestEnv()
+
+        try:
+            log.debug("starting tests")
+
+            scenario_sf = {
+                "title": "sizes and throughput (MB/s)",
+                "class": SingleFileLoadTest,
+                "location": "/",
+                "clients": 0,
+                "row0_title": "module protocol",
+                "row_title": "{module} {protocol}",
+                "rows": [
+                    {"module": "mod_ssl", "protocol": 'h1'},
+                    {"module": "mod_tls", "protocol": 'h1'},
+                    {"module": "mod_ssl", "protocol": 'h2'},
+                    {"module": "mod_tls", "protocol": 'h2'},
+                ],
+                "col_title": "{rsize}KB",
+                "columns": [],
+            }
+            scenario_mf = {
+                "title": "connections and throughput (MB/s)",
+                "class": MultiFileLoadTest,
+                "location": "/",
+                "file_count": 1024,
+                "file_sizes": [1, 2, 3, 4, 5, 10, 20, 30, 40, 50, 100, 10000],
+                "requests": 10000,
+                "row0_title": "module protocol",
+                "row_title": "{module} {protocol}",
+                "rows": [
+                    {"module": "mod_ssl", "protocol": 'h1'},
+                    {"module": "mod_tls", "protocol": 'h1'},
+                    {"module": "mod_ssl", "protocol": 'h2'},
+                    {"module": "mod_tls", "protocol": 'h2'},
+                ],
+                "col_title": "{clients}c",
+                "columns": [],
+            }
+            scenario_conn = {
+                "title": "connections",
+                "class": ConnectionLoadTest,
+                "location": "/",
+                "duration": timedelta(seconds=10),
+                "cooldown": timedelta(seconds=5),
+                "file_count": 12,
+                "file_sizes": [1, 2, 3, 4, 5, 10, 20, 30, 40, 50, 100, 10000],
+                "requests": 1,
+                "clients": 1,
+                "row0_title": "module protocol",
+                "row_title": "{module} {protocol}",
+                "rows": [
+                    {"module": "mod_ssl", "protocol": 'h1'},
+                    {"module": "mod_tls", "protocol": 'h1'},
+                    {"module": "mod_ssl", "protocol": 'h2'},
+                    {"module": "mod_tls", "protocol": 'h2'},
+                ],
+                "col_title": "{clients}c",
+                "columns": [],
+            }
+
+            scenarios = {
+                "1c-throughput": cls.scenario_with(scenario_sf, {
+                    "title": "1 conn, 1k-10k requests, *sizes, throughput (MB/s)",
+                    "clients": 1,
+                    "columns": [
+                        {"requests": 10000, "rsize": 10},
+                        {"requests": 6000, "rsize": 100},
+                        {"requests": 3000, "rsize": 1024},
+                        {"requests": 1000, "rsize": 10 * 1024},
+                    ],
+                }),
+                "10c-throughput": cls.scenario_with(scenario_sf, {
+                    "title": "10 conn, 5k-50k requests, *sizes, throughput (MB/s)",
+                    "clients": 10,
+                    "columns": [
+                        {"requests": 50000, "rsize": 10},
+                        {"requests": 25000, "rsize": 100},
+                        {"requests": 10000, "rsize": 1024},
+                        {"requests": 5000, "rsize": 10 * 1024},
+                    ],
+                }),
+                "20c-throughput": cls.scenario_with(scenario_sf, {
+                    "title": "20 conn, 5k-50k requests, *sizes, throughput (MB/s)",
+                    "clients": 20,
+                    "columns": [
+                        {"requests": 50000, "rsize": 10},
+                        {"requests": 25000, "rsize": 100},
+                        {"requests": 10000, "rsize": 1024},
+                        {"requests": 5000, "rsize": 10 * 1024},
+                    ],
+                }),
+                "50c-throughput": cls.scenario_with(scenario_sf, {
+                    "title": "50 conn, 10k-100k requests, *sizes, throughput (MB/s)",
+                    "clients": 50,
+                    "columns": [
+                        {"requests": 100000, "rsize": 10},
+                        {"requests": 50000, "rsize": 100},
+                        {"requests": 10000, "rsize": 1024},
+                        {"requests": 5000, "rsize": 10 * 1024},
+                    ],
+                }),
+                "1k-files": cls.scenario_with(scenario_mf, {
+                    "title": "1k files, 1k-10MB, *conn, 10k req, (req/s)",
+                    "clients": 1,
+                    "columns": [
+                        {"clients": 1},
+                        {"clients": 2},
+                        {"clients": 4},
+                        {"clients": 8},
+                        {"clients": 16},
+                        {"clients": 32},
+                        {"clients": 64},
+                    ],
+                }),
+                "1k-files-proxy-h1": cls.scenario_with(scenario_mf, {
+                    "location": "/proxy-h1/",
+                    "title": "1k files, h1 proxy, 1k-10MB, *conn, 10k req, (req/s)",
+                    "clients": 1,
+                    "columns": [
+                        {"clients": 1},
+                        {"clients": 2},
+                        {"clients": 4},
+                        {"clients": 8},
+                        {"clients": 16},
+                        {"clients": 32},
+                        {"clients": 64},
+                    ],
+                }),
+                "1k-files-proxy-h2": cls.scenario_with(scenario_mf, {
+                    "location": "/proxy-h2/",
+                    "title": "1k files, h2 proxy, 1k-10MB, *conn, 10k req, (req/s)",
+                    "clients": 1,
+                    "columns": [
+                        {"clients": 1},
+                        {"clients": 2},
+                        {"clients": 4},
+                        {"clients": 8},
+                        {"clients": 16},
+                        {"clients": 32},
+                        {"clients": 64},
+                    ],
+                }),
+                "1m-reqs": cls.scenario_with(scenario_mf, {
+                    "title": "1m requests, 1k files, 1k-10MB, (req/s)",
+                    "clients": 1,
+                    "requests": 1000000,
+                    "columns": [
+                        {"clients": 1},
+                        {"clients": 4},
+                        {"clients": 16},
+                        {"clients": 64},
+                    ],
+                }),
+                "conn-scale": cls.scenario_with(scenario_conn, {
+                    "title": "c parallel clients, 1 req/c (conn/s)",
+                    "requests": 1,
+                    "duration": timedelta(seconds=30),
+                    "cooldown": timedelta(seconds=10),
+                    "columns": [
+                        {"clients": 1},
+                        {"clients": 2},
+                        {"clients": 4},
+                        {"clients": 8},
+                        {"clients": 16},
+                        {"clients": 32},
+                    ],
+                }),
+                "conn-limits": cls.scenario_with(scenario_conn, {
+                    "title": "c parallel clients, 1 req/c (conn/s)",
+                    "requests": 1,
+                    "duration": timedelta(seconds=10),
+                    "cooldown": timedelta(seconds=30),
+                    "columns": [
+                        {"clients": 64},
+                        {"clients": 128},
+                        {"clients": 256},
+                        {"clients": 512},
+                    ],
+                }),
+            }
+            for name in args.names:
+                if name not in scenarios:
+                    raise LoadTestException(f"scenario unknown: '{name}'")
+            names = args.names if len(args.names) else sorted(scenarios.keys())
+
+            setup = TlsTestSetup(env=env)
+            env.setup_httpd(setup=setup)
+            
+            for name in names:
+                scenario = scenarios[name]
+                table = [
+                    [scenario['title']],
+                ]
+                foot_notes = []
+                headers = [scenario['row0_title']]
+                for col in scenario['columns']:
+                    headers.append(scenario['col_title'].format(**col))
+                table.append(headers)
+                cls.print_table(table)
+                for row in scenario['rows']:
+                    if args.module is not None and row['module'] != args.module:
+                        continue
+                    if args.protocol is not None and row['protocol'] != args.protocol:
+                        continue
+                    row_line = [scenario['row_title'].format(**row)]
+                    table.append(row_line)
+                    for col in scenario['columns']:
+                        t = scenario.copy()
+                        t.update(row)
+                        t.update(col)
+                        test = scenario['class'].from_scenario(t, env=env)
+                        env.httpd_error_log.clear_log()
+                        summary = test.run()
+                        result, fnote = test.format_result(summary)
+                        if fnote:
+                            foot_notes.append(fnote)
+                        row_line.append("{0}{1}".format(result,
+                                                        f"[{len(foot_notes)}]" if fnote else ""))
+                        cls.print_table(table, foot_notes)
+        except KeyboardInterrupt:
+            rv = 1
+        except LoadTestException as ex:
+            sys.stderr.write(f"ERROR: {str(ex)}\n")
+            rv = 1
+
+        env.apache_stop()
+        sys.exit(rv)
+
+
+if __name__ == "__main__":
+    LoadTest.main()

Added: httpd/httpd/trunk/test/modules/tls/test_01_apache.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_01_apache.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_01_apache.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_01_apache.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,14 @@
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestApache:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        TlsTestConf(env=env).install()
+        assert env.apache_restart() == 0
+
+    def test_01_apache_http(self, env):
+        assert env.is_live(env.http_base_url)

Added: httpd/httpd/trunk/test/modules/tls/test_02_conf.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_02_conf.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_02_conf.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_02_conf.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,138 @@
+import os
+from datetime import timedelta
+
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestConf:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        TlsTestConf(env=env).install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _function_scope(self, env):
+        if env.is_live(timeout=timedelta(milliseconds=100)):
+            assert env.apache_stop() == 0
+
+    def test_02_conf_cert_args_missing(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSCertificate")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    def test_02_conf_cert_single_arg(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSCertificate cert.pem")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    def test_02_conf_cert_file_missing(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSCertificate cert.pem key.pem")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    def test_02_conf_cert_file_exist(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSCertificate test-02-cert.pem test-02-key.pem")
+        conf.install()
+        for name in ["test-02-cert.pem", "test-02-key.pem"]:
+            with open(os.path.join(env.server_dir, name), "w") as fd:
+                fd.write("")
+        assert env.apache_fail() == 0
+
+    def test_02_conf_cert_listen_missing(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSEngine")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    def test_02_conf_cert_listen_wrong(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSEngine ^^^^^")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    @pytest.mark.parametrize("listen", [
+        "443",
+        "129.168.178.188:443",
+        "[::]:443",
+    ])
+    def test_02_conf_cert_listen_valid(self, env, listen: str):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSEngine {listen}".format(listen=listen))
+        conf.install()
+        assert env.apache_restart() == 0
+
+    def test_02_conf_cert_listen_cert(self, env):
+        domain = env.domain_a
+        conf = TlsTestConf(env=env)
+        conf.add_tls_vhosts(domains=[domain])
+        conf.install()
+        assert env.apache_restart() == 0
+
+    def test_02_conf_proto_wrong(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSProtocol wrong")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    @pytest.mark.parametrize("proto", [
+        "default",
+        "TLSv1.2+",
+        "TLSv1.3+",
+        "TLSv0x0303+",
+    ])
+    def test_02_conf_proto_valid(self, env, proto):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSProtocol {proto}".format(proto=proto))
+        conf.install()
+        assert env.apache_restart() == 0
+
+    def test_02_conf_honor_wrong(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSHonorClientOrder wrong")
+        conf.install()
+        assert env.apache_fail() == 0
+
+    @pytest.mark.parametrize("honor", [
+        "on",
+        "OfF",
+    ])
+    def test_02_conf_honor_valid(self, env, honor: str):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSHonorClientOrder {honor}".format(honor=honor))
+        conf.install()
+        assert env.apache_restart() == 0
+
+    @pytest.mark.parametrize("cipher", [
+        "default",
+        "TLS13_AES_128_GCM_SHA256:TLS13_AES_256_GCM_SHA384:TLS13_CHACHA20_POLY1305_SHA256",
+        "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:"
+        "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:"
+        "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
+        """TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256  TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 \\
+        TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384  TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384\\
+        TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"""
+    ])
+    def test_02_conf_cipher_valid(self, env, cipher):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSCiphersPrefer {cipher}".format(cipher=cipher))
+        conf.install()
+        assert env.apache_restart() == 0
+
+    @pytest.mark.parametrize("cipher", [
+        "wrong",
+        "YOLO",
+        "TLS_NULL_WITH_NULL_NULLX",       # not supported
+        "TLS_DHE_RSA_WITH_AES128_GCM_SHA256",     # not supported
+    ])
+    def test_02_conf_cipher_wrong(self, env, cipher):
+        conf = TlsTestConf(env=env)
+        conf.add("TLSCiphersPrefer {cipher}".format(cipher=cipher))
+        conf.install()
+        assert env.apache_fail() == 0

Added: httpd/httpd/trunk/test/modules/tls/test_03_sni.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_03_sni.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_03_sni.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_03_sni.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,73 @@
+from datetime import timedelta
+
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestSni:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        env.curl_supports_tls_1_3()  # init
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _function_scope(self, env):
+        pass
+
+    def test_03_sni_get_a(self, env):
+        # do we see the correct json for the domain_a?
+        data = env.tls_get_json(env.domain_a, "/index.json")
+        assert data == {'domain': env.domain_a}
+
+    def test_03_sni_get_b(self, env):
+        # do we see the correct json for the domain_a?
+        data = env.tls_get_json(env.domain_b, "/index.json")
+        assert data == {'domain': env.domain_b}
+
+    def test_03_sni_unknown(self, env):
+        # connection will be denied as cert does not cover this domain
+        domain_unknown = "unknown.test"
+        r = env.tls_get(domain_unknown, "/index.json")
+        assert r.exit_code != 0
+
+    def test_03_sni_request_other_same_config(self, env):
+        # do we see the first vhost respone for another domain with different certs?
+        r = env.tls_get(env.domain_a, "/index.json", options=[
+            "-vvvv", "--header", "Host: {0}".format(env.domain_b)
+        ])
+        # request is marked as misdirected
+        assert r.exit_code == 0
+        assert r.json is None
+        assert r.response['status'] == 421
+
+    def test_03_sni_request_other_other_honor(self, env):
+        if env.curl_supports_tls_1_3():
+            # can't do this test then
+            return
+        # do we see the first vhost respone for an unknown domain?
+        conf = TlsTestConf(env=env, extras={
+            env.domain_a: "TLSProtocol TLSv1.2+",
+            env.domain_b: "TLSProtocol TLSv1.3+"
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.tls_get(env.domain_a, "/index.json", options=[
+            "-vvvv", "--header", "Host: {0}".format(env.domain_b)
+        ])
+        # request denied
+        assert r.exit_code == 0
+        assert r.json is None
+
+    def test_03_sni_bad_hostname(self, env):
+        # curl checks hostnames we give it, but the openssl client
+        # does not. Good for us, since we need to test it.
+        r = env.openssl(["s_client", "-connect",
+                          "localhost:{0}".format(env.https_port),
+                          "-servername", b'x\x2f.y'.decode()])
+        assert r.exit_code == 1, r.stderr

Added: httpd/httpd/trunk/test/modules/tls/test_04_get.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_04_get.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_04_get.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_04_get.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,67 @@
+import os
+import time
+from datetime import timedelta
+
+import pytest
+
+from .env import TlsTestEnv
+from .conf import TlsTestConf
+
+
+def mk_text_file(fpath: str, lines: int):
+    t110 = 11 * "0123456789"
+    with open(fpath, "w") as fd:
+        for i in range(lines):
+            fd.write("{0:015d}: ".format(i))  # total 128 bytes per line
+            fd.write(t110)
+            fd.write("\n")
+
+
+class TestGet:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env)
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        docs_a = os.path.join(env.server_docs_dir, env.domain_a)
+        mk_text_file(os.path.join(docs_a, "1k.txt"), 8)
+        mk_text_file(os.path.join(docs_a, "10k.txt"), 80)
+        mk_text_file(os.path.join(docs_a, "100k.txt"), 800)
+        mk_text_file(os.path.join(docs_a, "1m.txt"), 8000)
+        mk_text_file(os.path.join(docs_a, "10m.txt"), 80000)
+        assert env.apache_restart() == 0
+
+    @pytest.mark.parametrize("fname, flen", [
+        ("1k.txt", 1024),
+        ("10k.txt", 10*1024),
+        ("100k.txt", 100 * 1024),
+        ("1m.txt", 1000 * 1024),
+        ("10m.txt", 10000 * 1024),
+    ])
+    def test_04_get(self, env, fname, flen):
+        # do we see the correct json for the domain_a?
+        docs_a = os.path.join(env.server_docs_dir, env.domain_a)
+        r = env.tls_get(env.domain_a, "/{0}".format(fname))
+        assert r.exit_code == 0
+        assert len(r.stdout) == flen
+        pref = os.path.join(docs_a, fname)
+        pout = os.path.join(docs_a, "{0}.out".format(fname))
+        with open(pout, 'w') as fd:
+            fd.write(r.stdout)
+        dr = env.run_diff(pref, pout)
+        assert dr.exit_code == 0, "differences found:\n{0}".format(dr.stdout)
+
+    @pytest.mark.parametrize("fname, flen", [
+        ("1k.txt", 1024),
+    ])
+    def test_04_double_get(self, env, fname, flen):
+        # we'd like to check that we can do >1 requests on the same connection
+        # however curl hides that from us, unless we analyze its verbose output
+        docs_a = os.path.join(env.server_docs_dir, env.domain_a)
+        r = env.tls_get(env.domain_a, paths=[
+            "/{0}".format(fname),
+            "/{0}".format(fname)
+        ])
+        assert r.exit_code == 0
+        assert len(r.stdout) == 2*flen

Added: httpd/httpd/trunk/test/modules/tls/test_05_proto.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_05_proto.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_05_proto.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_05_proto.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,67 @@
+import time
+from datetime import timedelta
+import socket
+from threading import Thread
+
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestProto:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env, extras={
+            env.domain_a: "TLSProtocol TLSv1.3+",
+            env.domain_b: [
+                "# the commonly used name",
+                "TLSProtocol TLSv1.2+",
+                "# the numeric one (yes, this is 1.2)",
+                "TLSProtocol TLSv0x0303+",
+            ],
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _function_scope(self, env):
+        pass
+
+    CURL_SUPPORTS_TLS_1_3 = None
+
+    def test_05_proto_1_2(self, env):
+        r = env.tls_get(env.domain_b, "/index.json", options=["--tlsv1.2"])
+        assert r.exit_code == 0, r.stderr
+        if env.curl_supports_tls_1_3():
+            r = env.tls_get(env.domain_b, "/index.json", options=["--tlsv1.3"])
+            assert r.exit_code == 0, r.stderr
+
+    def test_05_proto_1_3(self, env):
+        r = env.tls_get(env.domain_a, "/index.json", options=["--tlsv1.3"])
+        if env.curl_supports_tls_1_3():
+            assert r.exit_code == 0, r.stderr
+        else:
+            assert r.exit_code == 4, r.stderr
+
+    def test_05_proto_close(self, env):
+        s = socket.create_connection(('localhost', env.https_port))
+        time.sleep(0.1)
+        s.close()
+
+    def test_05_proto_ssl_close(self, env):
+        conf = TlsTestConf(env=env, extras={
+            'base': "LogLevel ssl:debug",
+            env.domain_a: "SSLProtocol TLSv1.3",
+            env.domain_b: "SSLProtocol TLSv1.2",
+        })
+        for d in [env.domain_a, env.domain_b]:
+            conf.add_vhost(domains=[d], port=env.https_port)
+        conf.install()
+        assert env.apache_restart() == 0
+        s = socket.create_connection(('localhost', env.https_port))
+        time.sleep(0.1)
+        s.close()
+
+

Added: httpd/httpd/trunk/test/modules/tls/test_06_ciphers.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_06_ciphers.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_06_ciphers.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_06_ciphers.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,209 @@
+import re
+from datetime import timedelta
+
+import pytest
+
+from .env import TlsTestEnv
+from .conf import TlsTestConf
+
+
+class TestCiphers:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env, extras={
+            'base': "TLSHonorClientOrder off",
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _function_scope(self, env):
+        pass
+
+    def _get_protocol_cipher(self, output: str):
+        protocol = None
+        cipher = None
+        for line in output.splitlines():
+            m = re.match(r'^\s+Protocol\s*:\s*(\S+)$', line)
+            if m:
+                protocol = m.group(1)
+                continue
+            m = re.match(r'^\s+Cipher\s*:\s*(\S+)$', line)
+            if m:
+                cipher = m.group(1)
+        return protocol, cipher
+
+    def test_06_ciphers_ecdsa(self, env):
+        ecdsa_1_2 = [c for c in env.RUSTLS_CIPHERS
+                     if c.max_version == 1.2 and c.flavour == 'ECDSA'][0]
+        # client speaks only this cipher, see that it gets it
+        r = env.openssl_client(env.domain_b, extra_args=[
+            "-cipher", ecdsa_1_2.openssl_name, "-tls1_2"
+        ])
+        protocol, cipher = self._get_protocol_cipher(r.stdout)
+        assert protocol == "TLSv1.2", r.stdout
+        assert cipher == ecdsa_1_2.openssl_name, r.stdout
+
+    def test_06_ciphers_rsa(self, env):
+        rsa_1_2 = [c for c in env.RUSTLS_CIPHERS
+                   if c.max_version == 1.2 and c.flavour == 'RSA'][0]
+        # client speaks only this cipher, see that it gets it
+        r = env.openssl_client(env.domain_b, extra_args=[
+            "-cipher", rsa_1_2.openssl_name, "-tls1_2"
+        ])
+        protocol, cipher = self._get_protocol_cipher(r.stdout)
+        assert protocol == "TLSv1.2", r.stdout
+        assert cipher == rsa_1_2.openssl_name, r.stdout
+
+    @pytest.mark.parametrize("cipher", [
+        c for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'ECDSA'
+    ], ids=[
+        c.name for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'ECDSA'
+    ])
+    def test_06_ciphers_server_prefer_ecdsa(self, env, cipher):
+        # Select a ECSDA ciphers as preference and suppress all RSA ciphers.
+        # The last is not strictly necessary since rustls prefers ECSDA anyway
+        suppress_names = [c.name for c in env.RUSTLS_CIPHERS
+                          if c.max_version == 1.2 and c.flavour == 'RSA']
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: [
+                "TLSHonorClientOrder off",
+                f"TLSCiphersPrefer {cipher.name}",
+                f"TLSCiphersSuppress {':'.join(suppress_names)}",
+            ]
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.openssl_client(env.domain_b, extra_args=["-tls1_2"])
+        client_proto, client_cipher = self._get_protocol_cipher(r.stdout)
+        assert client_proto == "TLSv1.2", r.stdout
+        assert client_cipher == cipher.openssl_name, r.stdout
+
+    @pytest.mark.skip(reason="Wrong certified key selected by rustls")
+    # see <https://github.com/rustls/rustls-ffi/issues/236>
+    @pytest.mark.parametrize("cipher", [
+        c for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'RSA'
+    ], ids=[
+        c.name for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'RSA'
+    ])
+    def test_06_ciphers_server_prefer_rsa(self, env, cipher):
+        # Select a RSA ciphers as preference and suppress all ECDSA ciphers.
+        # The last is necessary since rustls prefers ECSDA and openssl leaks that it can.
+        suppress_names = [c.name for c in env.RUSTLS_CIPHERS
+                          if c.max_version == 1.2 and c.flavour == 'ECDSA']
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: [
+                "TLSHonorClientOrder off",
+                f"TLSCiphersPrefer {cipher.name}",
+                f"TLSCiphersSuppress {':'.join(suppress_names)}",
+            ]
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.openssl_client(env.domain_b, extra_args=["-tls1_2"])
+        client_proto, client_cipher = self._get_protocol_cipher(r.stdout)
+        assert client_proto == "TLSv1.2", r.stdout
+        assert client_cipher == cipher.openssl_name, r.stdout
+
+    @pytest.mark.skip(reason="Wrong certified key selected by rustls")
+    # see <https://github.com/rustls/rustls-ffi/issues/236>
+    @pytest.mark.parametrize("cipher", [
+        c for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'RSA'
+    ], ids=[
+        c.openssl_name for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'RSA'
+    ])
+    def test_06_ciphers_server_prefer_rsa_alias(self, env, cipher):
+        # same as above, but using openssl names for ciphers
+        suppress_names = [c.openssl_name for c in env.RUSTLS_CIPHERS
+                          if c.max_version == 1.2 and c.flavour == 'ECDSA']
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: [
+                "TLSHonorClientOrder off",
+                f"TLSCiphersPrefer {cipher.openssl_name}",
+                f"TLSCiphersSuppress {':'.join(suppress_names)}",
+            ]
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.openssl_client(env.domain_b, extra_args=["-tls1_2"])
+        client_proto, client_cipher = self._get_protocol_cipher(r.stdout)
+        assert client_proto == "TLSv1.2", r.stdout
+        assert client_cipher == cipher.openssl_name, r.stdout
+
+    @pytest.mark.skip(reason="Wrong certified key selected by rustls")
+    # see <https://github.com/rustls/rustls-ffi/issues/236>
+    @pytest.mark.parametrize("cipher", [
+        c for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'RSA'
+    ], ids=[
+        c.id_name for c in TlsTestEnv.RUSTLS_CIPHERS if c.max_version == 1.2 and c.flavour == 'RSA'
+    ])
+    def test_06_ciphers_server_prefer_rsa_id(self, env, cipher):
+        # same as above, but using openssl names for ciphers
+        suppress_names = [c.id_name for c in env.RUSTLS_CIPHERS
+                          if c.max_version == 1.2 and c.flavour == 'ECDSA']
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: [
+                "TLSHonorClientOrder off",
+                f"TLSCiphersPrefer {cipher.id_name}",
+                f"TLSCiphersSuppress {':'.join(suppress_names)}",
+            ]
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        r = env.openssl_client(env.domain_b, extra_args=["-tls1_2"])
+        client_proto, client_cipher = self._get_protocol_cipher(r.stdout)
+        assert client_proto == "TLSv1.2", r.stdout
+        assert client_cipher == cipher.openssl_name, r.stdout
+
+    def test_06_ciphers_pref_unknown(self, env):
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: "TLSCiphersPrefer TLS_MY_SUPER_CIPHER:SSL_WHAT_NOT"
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() != 0
+        # get a working config again, so that subsequent test cases do not stumble
+        conf = TlsTestConf(env=env)
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        env.apache_restart()
+
+    def test_06_ciphers_pref_unsupported(self, env):
+        # a warning on prefering a known, but not supported cipher
+        env.httpd_error_log.ignore_recent()
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: "TLSCiphersPrefer TLS_NULL_WITH_NULL_NULL"
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        (errors, warnings) = env.httpd_error_log.get_recent_count()
+        assert errors == 0
+        assert warnings == 2  # once on dry run, once on start
+
+    def test_06_ciphers_supp_unknown(self, env):
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: "TLSCiphersSuppress TLS_MY_SUPER_CIPHER:SSL_WHAT_NOT"
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() != 0
+
+    def test_06_ciphers_supp_unsupported(self, env):
+        # no warnings on suppressing known, but not supported ciphers
+        env.httpd_error_log.ignore_recent()
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: "TLSCiphersSuppress TLS_NULL_WITH_NULL_NULL"
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+        (errors, warnings) = env.httpd_error_log.get_recent_count()
+        assert errors == 0
+        assert warnings == 0

Added: httpd/httpd/trunk/test/modules/tls/test_07_alpn.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_07_alpn.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_07_alpn.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_07_alpn.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,43 @@
+import re
+from datetime import timedelta
+
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestAlpn:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env, extras={
+            env.domain_b: "Protocols h2 http/1.1"
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _function_scope(self, env):
+        pass
+
+    def _get_protocol(self, output: str):
+        for line in output.splitlines():
+            m = re.match(r'^\*\s+ALPN, server accepted to use\s+(.*)$', line)
+            if m:
+                return m.group(1)
+        return None
+
+    def test_07_alpn_get_a(self, env):
+        # do we see the correct json for the domain_a?
+        r = env.tls_get(env.domain_a, "/index.json", options=["-vvvvvv"])
+        assert r.exit_code == 0, r.stderr
+        protocol = self._get_protocol(r.stderr)
+        assert protocol == "http/1.1", r.stderr
+
+    def test_07_alpn_get_b(self, env):
+        # do we see the correct json for the domain_a?
+        r = env.tls_get(env.domain_b, "/index.json", options=["-vvvvvv"])
+        assert r.exit_code == 0, r.stderr
+        protocol = self._get_protocol(r.stderr)
+        assert protocol == "h2", r.stderr

Added: httpd/httpd/trunk/test/modules/tls/test_08_vars.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_08_vars.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_08_vars.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_08_vars.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,63 @@
+import re
+from datetime import timedelta
+
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestVars:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env, extras={
+            'base': [
+                "TLSHonorClientOrder off",
+                "TLSOptions +StdEnvVars",
+            ]
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+
+    def test_08_vars_root(self, env):
+        # in domain_b root, the StdEnvVars is switch on
+        if env.curl_supports_tls_1_3():
+            exp_proto = "TLSv1.3"
+            exp_cipher = "TLS_AES_256_GCM_SHA384"
+        else:
+            exp_proto = "TLSv1.2"
+            exp_cipher = "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
+        r = env.tls_get(env.domain_b, "/vars.py")
+        assert r.exit_code == 0, r.stderr
+        assert r.json == {
+            'https': 'on',
+            'host': 'b.mod-tls.test',
+            'protocol': 'HTTP/1.1',
+            'ssl_protocol': exp_proto,
+            # this will vary by client potentially
+            'ssl_cipher': exp_cipher,
+        }, r.stdout
+
+    @pytest.mark.parametrize("name, value", [
+        ("SERVER_NAME", "b.mod-tls.test"),
+        ("SSL_SESSION_RESUMED", "Initial"),
+        ("SSL_SECURE_RENEG", "false"),
+        ("SSL_COMPRESS_METHOD", "NULL"),
+        ("SSL_CIPHER_EXPORT", "false"),
+        ("SSL_CLIENT_VERIFY", "NONE"),
+    ])
+    def test_08_vars_const(self, env, name: str, value: str):
+        r = env.tls_get(env.domain_b, f"/vars.py?name={name}")
+        assert r.exit_code == 0, r.stderr
+        assert r.json == {name: value}, r.stdout
+
+    @pytest.mark.parametrize("name, pattern", [
+        ("SSL_VERSION_INTERFACE", r'mod_tls/\d+\.\d+\.\d+'),
+        ("SSL_VERSION_LIBRARY", r'rustls-ffi/\d+\.\d+\.\d+/rustls/\d+\.\d+\.\d+'),
+    ])
+    def test_08_vars_match(self, env, name: str, pattern: str):
+        r = env.tls_get(env.domain_b, f"/vars.py?name={name}")
+        assert r.exit_code == 0, r.stderr
+        assert name in r.json
+        assert re.match(pattern, r.json[name]), r.json

Added: httpd/httpd/trunk/test/modules/tls/test_09_timeout.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/tls/test_09_timeout.py?rev=1895433&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/tls/test_09_timeout.py (added)
+++ httpd/httpd/trunk/test/modules/tls/test_09_timeout.py Tue Nov 30 16:30:26 2021
@@ -0,0 +1,43 @@
+import socket
+from datetime import timedelta
+
+import pytest
+
+from .conf import TlsTestConf
+
+
+class TestTimeout:
+
+    @pytest.fixture(autouse=True, scope='class')
+    def _class_scope(self, env):
+        conf = TlsTestConf(env=env, extras={
+            'base': "RequestReadTimeout handshake=1",
+        })
+        conf.add_tls_vhosts(domains=[env.domain_a, env.domain_b])
+        conf.install()
+        assert env.apache_restart() == 0
+
+    @pytest.fixture(autouse=True, scope='function')
+    def _function_scope(self, env):
+        pass
+
+    def test_09_timeout_handshake(self, env):
+        # in domain_b root, the StdEnvVars is switch on
+        s = socket.create_connection(('localhost', env.https_port))
+        s.send(b'1234')
+        s.settimeout(0.0)
+        try:
+            s.recv(1024)
+            assert False, "able to recv() on a TLS connection before we sent a hello"
+        except BlockingIOError:
+            pass
+        s.settimeout(3.0)
+        try:
+            while True:
+                buf = s.recv(1024)
+                if not buf:
+                    break
+                print("recv() -> {0}".format(buf))
+        except (socket.timeout, BlockingIOError):
+            assert False, "socket not closed as handshake timeout should trigger"
+        s.close()