diff --git a/test/modules/tls/env.py b/test/modules/tls/env.py index 8399d52..a05f73f 100644 --- a/test/modules/tls/env.py +++ b/test/modules/tls/env.py @@ -112,6 +112,7 @@ def __init__(self, pytestconfig=None): 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(domains=['localhost'], key_type='rsa4096'), CertificateSpec(name="clientsX", sub_specs=[ CertificateSpec(name="user1", client=True, single_file=True), CertificateSpec(name="user2", client=True, single_file=True), diff --git a/test/modules/tls/test_09_timeout.py b/test/modules/tls/test_09_timeout.py index 70cc894..6c49192 100644 --- a/test/modules/tls/test_09_timeout.py +++ b/test/modules/tls/test_09_timeout.py @@ -24,7 +24,8 @@ def _function_scope(self, env): def test_tls_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') + # something that looks like a ClientHello + s.send(bytes.fromhex('3c37121e')) s.settimeout(0.0) try: s.recv(1024) diff --git a/test/modules/tls/test_18_ws.py b/test/modules/tls/test_18_ws.py index 5ef90e8..16636e9 100644 --- a/test/modules/tls/test_18_ws.py +++ b/test/modules/tls/test_18_ws.py @@ -13,17 +13,81 @@ 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 WsServer: + + def __init__(self, name, env, port, creds=None): + self.name = name + self.env = env + self.process = None + self.cerr = None + self.port = port + self.creds = creds + self.run_dir = os.path.join(env.gen_dir, self.name) + self.err_file = os.path.join(self.run_dir, 'stderr') + self._rmrf(self.run_dir) + self._mkpath(self.run_dir) + + def start(self): + if not self.process: + self.cerr = open(self.err_file, 'w') + cmd = os.path.join(os.path.dirname(inspect.getfile(TestWebSockets)), + 'ws_server.py') + args = ['python3', cmd, '--port', str(self.port)] + if self.creds: + args.extend([ + '--cert', self.creds[0].cert_file, + '--key', self.creds[0].pkey_file, + ]) + self.process = subprocess.Popen(args=args, cwd=self.run_dir, + stderr=self.cerr, stdout=self.cerr) + if not self.check_alive(): + self.stop() + pytest.fail(f'ws_server did not start. stderr={open(self.err_file).readlines()}') + + def stop(self): + if self.process: + self.process.kill() + self.process.wait() + self.process = None + if self.cerr: + self.cerr.close() + self.cerr = None + + def check_alive(self, timeout=5): + if self.creds: + url = f'https://localhost:{self.port}/' + else: + url = f'http://localhost:{self.port}/' + end = datetime.now() + timedelta(seconds=timeout) + while datetime.now() < end: + r = self.env.curl_get(url, 5) + if r.exit_code == 0: + return True + time.sleep(.1) + return False + + def _mkpath(self, path): + if not os.path.exists(path): + return os.makedirs(path) + + def _rmrf(self, path): + if os.path.exists(path): + return shutil.rmtree(path) + class TestWebSockets: + @staticmethod + 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") + + @pytest.fixture(autouse=True, scope='class') def _class_scope(self, env): # Apache config that CONNECT proxies a WebSocket server for paths starting @@ -32,44 +96,52 @@ def _class_scope(self, env): conf = TlsTestConf(env, extras={ 'base': [ 'Timeout 1', + f'', + ' TLSProxyEngine on', + f' TLSProxyCA {env.ca.cert_file}', + ' ProxyPreserveHost on', + '', ], 'localhost': [ f'ProxyPass /ws/ http://127.0.0.1:{env.ws_port}/ upgrade=websocket \\', f'timeout=2 flushpackets=on', + f'ProxyPass /wss/ https://localhost:{env.wss_port}/ upgrade=websocket \\', + f'timeout=2 flushpackets=on', ], - f'cgi.{env.http_tld}': [ - f' ProxyPass /ws/ http://127.0.0.1:{env.ws_port}/ \\', - f' upgrade=websocket timeout=2 flushpackets=on', - f' ReadBufferSize 65535' - ] }) conf.add_vhost('localhost', port=env.http_port) conf.add_tls_vhosts(['localhost'], port=env.https_port) conf.install() - mk_text_file(os.path.join(env.gen_dir, "1k.txt"), 8) - mk_text_file(os.path.join(env.gen_dir, "10k.txt"), 80) - mk_text_file(os.path.join(env.gen_dir, "100k.txt"), 800) - mk_text_file(os.path.join(env.gen_dir, "1m.txt"), 8000) - mk_text_file(os.path.join(env.gen_dir, "10m.txt"), 80000) + TestWebSockets.mk_text_file(os.path.join(env.gen_dir, "1k.txt"), 8) + TestWebSockets.mk_text_file(os.path.join(env.gen_dir, "10k.txt"), 80) + TestWebSockets.mk_text_file(os.path.join(env.gen_dir, "100k.txt"), 800) + TestWebSockets.mk_text_file(os.path.join(env.gen_dir, "1m.txt"), 8000) assert env.apache_restart() == 0 - def ws_check_alive(self, env, timeout=5): - url = f'http://localhost:{env.ws_port}/' - end = datetime.now() + timedelta(seconds=timeout) - while datetime.now() < end: - r = env.curl_get(url, 5) - if r.exit_code == 0: - return True - time.sleep(.1) - return False - - def _mkpath(self, path): - if not os.path.exists(path): - return os.makedirs(path) + @pytest.fixture(autouse=True, scope='class') + def ws_server(self, env): + # Run our python websockets server that has some special behaviour + # for the different path to CONNECT to. + ws_server = WsServer('ws-server', env, port=env.ws_port) + ws_server.start() + yield ws_server + ws_server.stop() - def _rmrf(self, path): - if os.path.exists(path): - return shutil.rmtree(path) + @pytest.fixture(autouse=True, scope='class') + def wss_server(self, env): + # Run our python websockets server that has some special behaviour + # for the different path to CONNECT to. + creds = env.get_credentials_for_name('localhost') + assert creds + ws_server = WsServer('wss-server', env, port=env.wss_port, creds=creds) + ws_server.start() + yield ws_server + ws_server.stop() + + def ssl_ctx(self, env): + ssl_ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) + ssl_ctx.load_verify_locations(cafile=env.ca.cert_file) + return ssl_ctx def ws_recv_text(self, ws): msg = "" @@ -87,55 +159,54 @@ def ws_recv_bytes(self, ws): except websockets.exceptions.ConnectionClosedOK: return msg - @pytest.fixture(autouse=True, scope='class') - def ws_server(self, env): - # Run our python websockets server that has some special behaviour - # for the different path to CONNECT to. - run_dir = os.path.join(env.gen_dir, 'ws-server') - err_file = os.path.join(run_dir, 'stderr') - self._rmrf(run_dir) - self._mkpath(run_dir) - with open(err_file, 'w') as cerr: - cmd = os.path.join(os.path.dirname(inspect.getfile(TestWebSockets)), - 'ws_server.py') - args = ['python3', cmd, '--port', str(env.ws_port)] - p = subprocess.Popen(args=args, cwd=run_dir, stderr=cerr, - stdout=cerr) - if not self.ws_check_alive(env): - p.kill() - p.wait() - pytest.fail(f'ws_server did not start. stderr={open(err_file).readlines()}') - yield - p.terminate() - - def test_tls_18_01_direct(self, env): + # verify the our plain websocket server works + def test_tls_18_01_ws_direct(self, env, ws_server): with connect(f"ws://127.0.0.1:{env.ws_port}/echo") as ws: message = "Hello world!" ws.send(message) response = self.ws_recv_text(ws) assert response == message - def test_tls_18_02_httpd_plain(self, env): + # verify that our secure websocket server works + def test_tls_18_02_wss_direct(self, env, wss_server): + pytest.skip(reason='For unknown reasons, this is flaky in CI') + with connect(f"wss://localhost:{env.wss_port}/echo", + ssl_context=self.ssl_ctx(env)) as ws: + message = "Hello world!" + ws.send(message) + response = self.ws_recv_text(ws) + assert response == message + + # verify to send plain websocket message pingpong through apache + def test_tls_18_03_http_ws(self, env, ws_server): with connect(f"ws://localhost:{env.http_port}/ws/echo/") as ws: message = "Hello world!" ws.send(message) response = self.ws_recv_text(ws) assert response == message - @pytest.mark.parametrize("fname", ["1k.txt", "10k.txt", "100k.txt", "1m.txt", "10m.txt"]) - def test_tls_18_03_file(self, env, fname): + # verify to send secure websocket message pingpong through apache + def test_tls_18_04_http_wss(self, env, wss_server): + pytest.skip(reason='This fails, needing a fix like PR #9') + with connect(f"ws://localhost:{env.http_port}/wss/echo/") as ws: + message = "Hello world!" + ws.send(message) + response = self.ws_recv_text(ws) + assert response == message + + # verify that getting a large file works without any TLS involved + @pytest.mark.parametrize("fname", ["1m.txt"]) + def test_tls_18_05_http_ws_file(self, env, fname, ws_server): expected = open(os.path.join(env.gen_dir, fname), 'rb').read() with connect(f"ws://localhost:{env.http_port}/ws/file/{fname}") as ws: response = self.ws_recv_bytes(ws) assert response == expected - @pytest.mark.parametrize("fname", ["1k.txt", "10k.txt", "100k.txt", "1m.txt", "10m.txt"]) - def test_tls_18_04_tls_file(self, env, fname): + # verify getting secure websocket from the http: server + # this is "backend" mod_tls work + @pytest.mark.parametrize("fname", ["1k.txt", "10k.txt", "100k.txt", "1m.txt"]) + def test_tls_18_06_http_wss_file(self, env, fname, ws_server): expected = open(os.path.join(env.gen_dir, fname), 'rb').read() - ssl_ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) - ssl_ctx.check_hostname = False - ssl_ctx.verify_mode = ssl.VerifyMode.CERT_NONE - with connect(f"wss://localhost:{env.https_port}/ws/file/{fname}", - ssl_context=ssl_ctx) as ws: + with connect(f"ws://localhost:{env.http_port}/wss/file/{fname}") as ws: response = self.ws_recv_bytes(ws) assert response == expected diff --git a/test/modules/tls/ws_server.py b/test/modules/tls/ws_server.py index 99fb9cf..8c2760c 100755 --- a/test/modules/tls/ws_server.py +++ b/test/modules/tls/ws_server.py @@ -3,6 +3,7 @@ import asyncio import logging import os +import ssl import sys import time @@ -75,10 +76,14 @@ async def on_async_conn(conn): await conn.close(code=1000, reason='') -async def run_server(port): - log.info(f'starting server on port {port}') +async def run_server(port, cert, pkey): + log.info(f'starting server on port {port}, cert {cert}, key {pkey}') + ssl_ctx = None + if cert and pkey: + ssl_ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(certfile=cert, keyfile=pkey) async with ws_server.serve(ws_handler=on_async_conn, - host="localhost", port=port): + host="localhost", port=port, ssl=ssl_ctx): await asyncio.Future() @@ -87,6 +92,10 @@ async def main(): description="Run a websocket echo server.") parser.add_argument("--port", type=int, default=0, help="port to listen on") + parser.add_argument("--cert", type=str, + default=None, help="TLS certificate") + parser.add_argument("--key", type=str, + default=None, help="TLS private key") args = parser.parse_args() if args.port == 0: @@ -97,7 +106,7 @@ async def main(): format="%(asctime)s %(message)s", level=logging.DEBUG, ) - await run_server(args.port) + await run_server(args.port, args.cert, args.key) if __name__ == "__main__": diff --git a/test/pyhttpd/config.ini.in b/test/pyhttpd/config.ini.in index 85c0cfa..582ce45 100644 --- a/test/pyhttpd/config.ini.in +++ b/test/pyhttpd/config.ini.in @@ -29,6 +29,7 @@ https_port = 5001 proxy_port = 5003 http_port2 = 5004 ws_port = 5100 +wss_port = 5101 http_tld = tests.httpd.apache.org test_dir = @abs_srcdir@ test_src_dir = @abs_srcdir@ diff --git a/test/pyhttpd/env.py b/test/pyhttpd/env.py index 6f2b285..be20516 100644 --- a/test/pyhttpd/env.py +++ b/test/pyhttpd/env.py @@ -275,6 +275,7 @@ def __init__(self, pytestconfig=None): self._https_port = int(self.config.get('test', 'https_port')) self._proxy_port = int(self.config.get('test', 'proxy_port')) self._ws_port = int(self.config.get('test', 'ws_port')) + self._wss_port = int(self.config.get('test', 'wss_port')) self._http_tld = self.config.get('test', 'http_tld') self._test_dir = self.config.get('test', 'test_dir') self._clients_dir = os.path.join(os.path.dirname(self._test_dir), 'clients') @@ -404,6 +405,10 @@ def proxy_port(self) -> int: def ws_port(self) -> int: return self._ws_port + @property + def wss_port(self) -> int: + return self._wss_port + @property def http_tld(self) -> str: return self._http_tld