From 86922d1ed764b6aa91c2f9096cc4acdc8d5ff4e5 Mon Sep 17 00:00:00 2001 From: Mo Chen Date: Fri, 19 Jun 2026 14:24:30 -0500 Subject: [PATCH] tests: add TLS gold tests Increase autest coverage with the following tests: - TLS renegotiation, rejected by default and allowed when configured - the TLS record-size clamp, checked against on-the-wire record sizes - a failed outbound TLS origin connection surfaced as a 5xx - a TLS origin that resets the connection mid-request-body --- AGENTS.md | 7 + tests/gold_tests/tls/tls_flow_control.test.py | 116 +++++++++++ .../gold_tests/tls/tls_flow_control_client.py | 100 ++++++++++ .../tls/tls_origin_open_failed.test.py | 78 ++++++++ .../tls/tls_origin_post_abort.test.py | 114 +++++++++++ tests/gold_tests/tls/tls_post_abort_client.py | 127 ++++++++++++ tests/gold_tests/tls/tls_post_abort_origin.py | 92 +++++++++ tests/gold_tests/tls/tls_record_size.test.py | 117 +++++++++++ .../gold_tests/tls/tls_record_size_client.py | 155 +++++++++++++++ .../tls/tls_reload_under_load.test.py | 100 ++++++++++ .../tls/tls_reload_under_load_client.py | 183 ++++++++++++++++++ .../gold_tests/tls/tls_renegotiation.test.py | 124 ++++++++++++ .../tls/tls_renegotiation_allowed.test.py | 145 ++++++++++++++ .../tls/tls_renegotiation_allowed_client.py | 132 +++++++++++++ .../tls/tls_renegotiation_client.py | 67 +++++++ 15 files changed, 1657 insertions(+) create mode 100644 tests/gold_tests/tls/tls_flow_control.test.py create mode 100644 tests/gold_tests/tls/tls_flow_control_client.py create mode 100644 tests/gold_tests/tls/tls_origin_open_failed.test.py create mode 100644 tests/gold_tests/tls/tls_origin_post_abort.test.py create mode 100644 tests/gold_tests/tls/tls_post_abort_client.py create mode 100644 tests/gold_tests/tls/tls_post_abort_origin.py create mode 100644 tests/gold_tests/tls/tls_record_size.test.py create mode 100644 tests/gold_tests/tls/tls_record_size_client.py create mode 100644 tests/gold_tests/tls/tls_reload_under_load.test.py create mode 100644 tests/gold_tests/tls/tls_reload_under_load_client.py create mode 100644 tests/gold_tests/tls/tls_renegotiation.test.py create mode 100644 tests/gold_tests/tls/tls_renegotiation_allowed.test.py create mode 100644 tests/gold_tests/tls/tls_renegotiation_allowed_client.py create mode 100644 tests/gold_tests/tls/tls_renegotiation_client.py diff --git a/AGENTS.md b/AGENTS.md index 333da8ba1f5..e045c3c648f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,13 @@ regularly). using the Proxy Verifier format. This is simpler, more maintainable, and parseable by tools. +**Python conventions for test and helper scripts:** +- Launch Python helpers with `{sys.executable}` rather than a hardcoded `python3`, + so the test runs under the same interpreter the harness uses. +- Prefer f-strings over `str.format()` when building command lines, config lines, + and `Testers` expressions. +- Add type annotations to helper functions. + **For complete details on writing autests, see:** - `doc/developer-guide/testing/autests.en.rst` - Comprehensive guide to autest - Proxy Verifier format: https://github.com/yahoo/proxy-verifier diff --git a/tests/gold_tests/tls/tls_flow_control.test.py b/tests/gold_tests/tls/tls_flow_control.test.py new file mode 100644 index 00000000000..f1da9221825 --- /dev/null +++ b/tests/gold_tests/tls/tls_flow_control.test.py @@ -0,0 +1,116 @@ +''' +Exercise HTTP tunnel flow control (proxy.config.http.flow_control) on a TLS +client connection: a slow reader makes ATS throttle the origin and unthrottle as +the buffered data drains. The full response body must still be delivered. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +Test.Summary = __doc__ + + +class TestTlsFlowControl: + '''Verify a slow TLS reader under flow control still receives the whole body.''' + + # Comfortably larger than the water marks so the tunnel throttles and has to + # unthrottle many times over the transfer. + _body_len: int = 8 * 1024 * 1024 + _high_water: int = 64 * 1024 + _low_water: int = 32 * 1024 + + _server_counter: int = 0 + _ts_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + self._server = self._configure_server() + self._ts = self._configure_trafficserver() + + def _configure_server(self) -> 'Process': + '''Configure the origin server with a large response body. + + :return: The origin server Process. + ''' + server = Test.MakeOriginServer(f'server-{TestTlsFlowControl._server_counter}') + TestTlsFlowControl._server_counter += 1 + + request_header = {"headers": "GET /obj HTTP/1.1\r\nHost: ex.test\r\n\r\n", "timestamp": "1469733493.993", "body": ""} + response_header = { + "headers": + "HTTP/1.1 200 OK\r\nServer: microserver\r\nConnection: close\r\n" + f"Content-Length: {TestTlsFlowControl._body_len}\r\n\r\n", + "timestamp": "1469733493.993", + "body": "x" * TestTlsFlowControl._body_len + } + server.addResponse("sessionlog.json", request_header, response_header) + return server + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server with HTTP flow control enabled over TLS. + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestTlsFlowControl._ts_counter}', enable_tls=True, enable_cache=False) + TestTlsFlowControl._ts_counter += 1 + + ts.addDefaultSSLFiles() + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.Port}') + ts.Disk.records_config.update( + { + 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}', + 'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}', + # Small thresholds on a network sink: the tunnel reenable fires before + # the socket write, so the unthrottle depends on the buffer draining. + 'proxy.config.http.flow_control.enabled': 1, + 'proxy.config.http.flow_control.high_water': TestTlsFlowControl._high_water, + 'proxy.config.http.flow_control.low_water': TestTlsFlowControl._low_water, + }) + + ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + "received signal|failed assertion", "ATS must not crash under TLS flow control") + ts.Disk.traffic_out.Content += Testers.ExcludesExpression( + "AddressSanitizer|use-after-free|runtime error:", "no memory-safety error under TLS flow control") + return ts + + def run(self) -> None: + '''Configure and run the TestRun.''' + tr = Test.AddTestRun("slow TLS reader under flow control must receive the whole body") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Command = ( + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_flow_control_client.py")} ' + f'-p {self._ts.Variables.ssl_port} --host ex.test --path /obj ' + f'--expect-bytes {TestTlsFlowControl._body_len}') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.All = Testers.ContainsExpression( + "RESULT=PASS", "the full body must be delivered under flow control") + tr.Processes.Default.Streams.All += Testers.ContainsExpression( + f"BODY_BYTES={TestTlsFlowControl._body_len}", "every body byte must arrive (no flow-control stall)") + tr.StillRunningAfter = self._ts + tr.StillRunningAfter = self._server + + +TestTlsFlowControl().run() diff --git a/tests/gold_tests/tls/tls_flow_control_client.py b/tests/gold_tests/tls/tls_flow_control_client.py new file mode 100644 index 00000000000..66565c19121 --- /dev/null +++ b/tests/gold_tests/tls/tls_flow_control_client.py @@ -0,0 +1,100 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Download a large object from ATS over TLS while reading the response body +slowly, to exercise HTTP tunnel flow control (proxy.config.http.flow_control) +on a TLS client connection. + +A slow reader keeps ATS's client-side write buffer full, so the tunnel +repeatedly throttles the origin read and must unthrottle as the buffered data +drains to the client. The whole body must still arrive; a flow-control stall +(no unthrottle) shows up as a short or timed-out read. This path had no prior +gold coverage, and the layered TLS VConnection drives the unthrottle through its +demand-driven write rather than the inherited write-buffer-empty trap, so it is +worth guarding directly. +""" + +import argparse +import socket +import ssl +import sys +import time + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("-p", "--port", type=int, required=True, help="ATS TLS port") + parser.add_argument("--host", default="ex.test", help="Host header / SNI") + parser.add_argument("--path", default="/obj", help="request path") + parser.add_argument("--expect-bytes", type=int, required=True, help="expected body length") + parser.add_argument("--read-size", type=int, default=16 * 1024, help="bytes per recv") + parser.add_argument("--read-delay", type=float, default=0.002, help="sleep between recvs (s)") + parser.add_argument("--recv-timeout", type=float, default=15.0, help="per-recv socket timeout (s)") + parser.add_argument("--deadline", type=float, default=120.0, help="overall wall-clock budget (s)") + args = parser.parse_args() + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + deadline = time.monotonic() + args.deadline + body_received = 0 + error = "" + try: + with socket.create_connection(("127.0.0.1", args.port), timeout=args.recv_timeout) as sock: + with ctx.wrap_socket(sock, server_hostname=args.host) as ssock: + ssock.settimeout(args.recv_timeout) + request = (f"GET {args.path} HTTP/1.1\r\nHost: {args.host}\r\n" + "Connection: close\r\n\r\n").encode() + ssock.sendall(request) + + # Read until we have the full header block, keeping any body bytes + # that arrive in the same recv. + buf = b"" + while b"\r\n\r\n" not in buf: + if time.monotonic() > deadline: + raise TimeoutError("deadline reached reading response headers") + chunk = ssock.recv(args.read_size) + if not chunk: + raise ConnectionError("connection closed before headers complete") + buf += chunk + header_blob, _, leftover = buf.partition(b"\r\n\r\n") + body_received = len(leftover) + + # Drain the body slowly so ATS's client-side write buffer stays full + # and the tunnel relies on the buffer-empty unthrottle. + while body_received < args.expect_bytes: + if time.monotonic() > deadline: + raise TimeoutError(f"deadline reached after {body_received} body bytes") + time.sleep(args.read_delay) + chunk = ssock.recv(args.read_size) + if not chunk: + break + body_received += len(chunk) + except Exception as exc: # noqa: BLE001 - any failure is a test signal + error = f"{type(exc).__name__}: {exc}" + + passed = error == "" and body_received == args.expect_bytes + + print(f"BODY_BYTES={body_received}") + print(f"EXPECT_BYTES={args.expect_bytes}") + if error: + print(f"ERROR={error}") + print(f"RESULT={'PASS' if passed else 'FAIL'}") + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/gold_tests/tls/tls_origin_open_failed.test.py b/tests/gold_tests/tls/tls_origin_open_failed.test.py new file mode 100644 index 00000000000..52092aadf0a --- /dev/null +++ b/tests/gold_tests/tls/tls_origin_open_failed.test.py @@ -0,0 +1,78 @@ +''' +Verify that a failed outbound TLS origin connection is surfaced to the client as +an error (5xx) without crashing ATS. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ports + +Test.Summary = __doc__ + + +class TestOriginOpenFailed: + '''Verify a failed outbound TLS connect is surfaced as a 5xx, not a crash.''' + + _ts_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + self._ts = self._configure_trafficserver() + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server with an https origin whose connect fails. + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestOriginOpenFailed._ts_counter}') + TestOriginOpenFailed._ts_counter += 1 + + # Reserve a port for a TLS origin; its liveness is irrelevant since the + # source bind below fails before any connect is attempted. + ports.get_port(ts, 'origin_port') + + ts.Disk.remap_config.AddLine(f'map http://dead.test/ https://127.0.0.1:{ts.Variables.origin_port}/') + ts.Disk.records_config.update( + { + # Bind every outbound connection to a non-local source address + # (RFC 5737 documentation range) so bind() fails synchronously with + # EADDRNOTAVAIL, i.e. the connect fails before any TCP/TLS exchange + # rather than via a later, routable connect that would be refused + # asynchronously. + 'proxy.config.outgoing_ip_to_bind': '192.0.2.1', + 'proxy.config.http.connect_attempts_max_retries': 1, + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'http|ssl', + }) + + # Tearing down the failed outbound TLS connect must not crash ATS. + ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + "received signal|failed assertion", "ATS must not crash on a failed outbound TLS connect") + return ts + + def run(self) -> None: + '''Configure and run the TestRun.''' + tr = Test.AddTestRun("a failed outbound TLS connect is surfaced cleanly") + tr.Processes.Default.StartBefore(self._ts) + tr.MakeCurlCommand( + f'-s -o /dev/null -w "%{{http_code}}" -H "Host: dead.test" http://127.0.0.1:{self._ts.Variables.port}/', ts=self._ts) + tr.Processes.Default.ReturnCode = 0 + # A failed origin connection is surfaced as a 5xx (502 Bad Gateway). + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression("50[02]", "a failed origin connect yields a 5xx") + tr.StillRunningAfter = self._ts + + +TestOriginOpenFailed().run() diff --git a/tests/gold_tests/tls/tls_origin_post_abort.test.py b/tests/gold_tests/tls/tls_origin_post_abort.test.py new file mode 100644 index 00000000000..4d989a26ef6 --- /dev/null +++ b/tests/gold_tests/tls/tls_origin_post_abort.test.py @@ -0,0 +1,114 @@ +''' +When a TLS origin resets the connection while ATS is still sending it a POST +request body, ATS must fail the transaction promptly -- the transport error must +reach the request-body tunnel right away as VC_EVENT_ERROR, not sit until the +outbound inactivity timeout rescues it. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +Test.Summary = __doc__ + + +class TestOriginPostAbort: + '''Verify a TLS origin RST mid-POST-body fails the transaction promptly.''' + + _ts_counter: int = 0 + _origin_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + Test.GetTcpPort("origin_port") + self._ts = self._configure_trafficserver() + self._origin = self._configure_origin() + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server with a TLS origin and a generous rescue timeout. + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestOriginPostAbort._ts_counter}', enable_cache=False) + TestOriginPostAbort._ts_counter += 1 + + ts.Disk.records_config.update( + { + 'proxy.config.url_remap.remap_required': 1, + 'proxy.config.http.connect_attempts_max_retries': 0, + 'proxy.config.http.connect_attempts_timeout': 15, + # The rescue timeout: a prompt failure must beat this by a wide margin. + 'proxy.config.http.transaction_no_activity_timeout_out': 15, + 'proxy.config.http.transaction_no_activity_timeout_in': 30, + # Keep the kernel send buffer small so the request body cannot be + # absorbed by the kernel: ATS must still be mid-send at the RST. + 'proxy.config.net.sock_send_buffer_size_out': 65536, + 'proxy.config.ssl.client.verify.server.policy': 'DISABLED', + 'proxy.config.diags.debug.enabled': 0, + 'proxy.config.diags.debug.tags': 'http|ssl|ssl_io', + }) + ts.Disk.remap_config.AddLine(f'map /post https://127.0.0.1:{Test.Variables.origin_port}') + + ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + 'received signal|failed assertion', 'ATS must not crash handling the mid-body origin abort') + return ts + + def _configure_origin(self) -> 'Process': + '''Configure a raw-socket TLS origin that resets mid-request-body. + + The scenario needs a TLS origin that accepts the request body and then RSTs + the connection mid-body (SO_LINGER 0). Proxy Verifier can only close cleanly + (close_notify), so it cannot express a mid-body reset; hence the small + raw-socket origin helper rather than an ATSReplayTest. + + :return: The origin server Process. + ''' + origin = Test.Processes.Process( + f'origin-{TestOriginPostAbort._origin_counter}', + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_post_abort_origin.py")} ' + f'-p {Test.Variables.origin_port} ' + f'-c {os.path.join(Test.Variables.AtsTestToolsDir, "ssl", "server.pem")} -d 1.0') + TestOriginPostAbort._origin_counter += 1 + + # These markers prove the reset path was actually exercised: the origin + # completed the TLS handshake, received the forwarded request headers, and + # then reset the connection mid-body. Without them the client could pass on + # any prompt response -- e.g. a broken remap or a never-started origin that + # yields a fast 5xx without the body ever reaching a resetting origin. + origin.Streams.stdout = Testers.ContainsExpression( + 'request headers received', 'the origin must receive the forwarded request headers') + origin.Streams.stdout += Testers.ContainsExpression( + 'connection reset sent', 'the origin must reset the connection mid-body') + return origin + + def run(self) -> None: + '''Configure and run the TestRun.''' + tr = Test.AddTestRun("POST to a TLS origin that resets mid-body must fail promptly") + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.StartBefore(self._origin, ready=When.PortOpen(Test.Variables.origin_port)) + tr.Processes.Default.Command = ( + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_post_abort_client.py")} ' + f'-p {self._ts.Variables.port} -t 8') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.All += Testers.ContainsExpression( + 'PASS: transaction failed promptly', 'the POST must fail promptly on the origin RST') + tr.Processes.Default.Streams.All += Testers.ContainsExpression( + 'status-code: 5', 'ATS must surface the mid-body origin reset as a 5xx') + tr.StillRunningAfter = self._ts + + +TestOriginPostAbort().run() diff --git a/tests/gold_tests/tls/tls_post_abort_client.py b/tests/gold_tests/tls/tls_post_abort_client.py new file mode 100644 index 00000000000..afd02385a11 --- /dev/null +++ b/tests/gold_tests/tls/tls_post_abort_client.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +''' +POST a large request body through ATS and measure how long ATS takes to fail +the transaction after the origin aborts the connection mid-body. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import select +import socket +import sys +import time + + +def parse_status_code(status_line: str) -> 'int | None': + '''Return the numeric HTTP status from a status line, or None if absent.''' + fields = status_line.split() + if len(fields) >= 2 and fields[0].startswith('HTTP/') and fields[1].isdigit(): + return int(fields[1]) + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description='POST through ATS; time how fast the transaction fails.') + parser.add_argument('-p', '--proxy-port', type=int, required=True, help='ATS HTTP port') + parser.add_argument('-s', '--size', type=int, default=64 * 1024 * 1024, help='request body size in bytes') + parser.add_argument( + '-t', '--threshold', type=float, default=8.0, help='maximum seconds from request start until ATS fails the transaction') + parser.add_argument('--timeout', type=float, default=60.0, help='give up entirely after this many seconds') + parser.add_argument( + '--pause-after', type=int, default=0, help='stop sending body bytes after this many (0: keep sending until done)') + args = parser.parse_args() + + start = time.monotonic() + sock = socket.create_connection(('127.0.0.1', args.proxy_port), timeout=10) + request = (f'POST /post HTTP/1.1\r\n' + f'Host: origin\r\n' + f'Content-Length: {args.size}\r\n' + f'Connection: close\r\n' + f'\r\n').encode() + sock.sendall(request) + sock.setblocking(False) + + chunk = b'x' * 65536 + sent = 0 + response = b'' + outcome = None + while time.monotonic() - start < args.timeout: + want_write = sent < args.size and not (args.pause_after and sent >= args.pause_after) + readable, writable, _ = select.select([sock], [sock] if want_write else [], [], 1.0) + if readable: + try: + data = sock.recv(65536) + except (ConnectionResetError, BrokenPipeError, OSError) as e: + outcome = f'connection error while reading: {e.__class__.__name__}' + break + if not data: + outcome = 'connection closed by ATS' + break + response += data + if b'\r\n\r\n' in response: + outcome = 'response received' + break + if writable: + try: + sent += sock.send(chunk[:min(len(chunk), args.size - sent)]) + except (ConnectionResetError, BrokenPipeError, OSError) as e: + outcome = f'connection error while sending: {e.__class__.__name__}' + break + if sent >= args.size: + outcome = 'entire body sent' + break + + elapsed = time.monotonic() - start + if outcome is None: + outcome = 'gave up waiting' + status_line = response.split(b'\r\n', 1)[0].decode(errors='replace') if response else '' + status_code = parse_status_code(status_line) + print(f'outcome: {outcome}') + print(f'status-line: {status_line}') + print(f'status-code: {status_code if status_code is not None else ""}') + print(f'body-bytes-sent: {sent}') + print(f'elapsed: {elapsed:.3f}') + + # The transaction must actually have failed, not completed. + if outcome in ('entire body sent', 'gave up waiting'): + print('FAIL: transaction did not fail at all') + return 2 + # The client must have started streaming the request body; zero bytes sent + # would mean ATS failed at the request headers, not the mid-body path. (That + # the body reached a resetting origin is proven by the origin-side markers the + # test asserts, not by this client-side count.) + if sent <= 0: + print('FAIL: ATS failed before any request body was sent; mid-body path not exercised') + return 1 + # A prompt failure must beat the inactivity-timeout rescue by a wide margin. + if elapsed > args.threshold: + print(f'FAIL: transaction failed only after {elapsed:.3f}s (threshold {args.threshold}s)') + return 1 + # The upstream reset must be surfaced as a 5xx, not masked as success and not + # a bare connection drop with no status at all. + if status_code is None: + print('FAIL: ATS dropped the connection without surfacing a response status') + return 1 + if not 500 <= status_code <= 599: + print(f'FAIL: ATS surfaced status {status_code}; expected a 5xx for the origin reset') + return 1 + print('PASS: transaction failed promptly') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/gold_tests/tls/tls_post_abort_origin.py b/tests/gold_tests/tls/tls_post_abort_origin.py new file mode 100644 index 00000000000..8300358a31c --- /dev/null +++ b/tests/gold_tests/tls/tls_post_abort_origin.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +''' +A TLS origin that accepts one connection, reads the request headers, then +stops reading and aborts the connection with a TCP RST while the proxy is +still sending the request body. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import socket +import ssl +import struct +import sys +import time + + +def main() -> int: + parser = argparse.ArgumentParser(description='TLS origin that RSTs mid-request-body.') + parser.add_argument('-p', '--port', type=int, required=True, help='port to listen on') + parser.add_argument('-c', '--cert', required=True, help='PEM file with certificate and key') + parser.add_argument('-d', '--delay', type=float, default=1.0, help='seconds to wait after the headers before the RST') + args = parser.parse_args() + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(args.cert) + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # Keep the receive window small so the proxy cannot park the whole request + # body in kernel buffers: it must still be mid-send when the RST arrives. + listener.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536) + listener.bind(('127.0.0.1', args.port)) + listener.listen(5) + print(f'listening on {args.port}', flush=True) + + # Accept until a connection completes the TLS handshake and delivers the + # request headers: the test harness probes the port (a bare TCP connect + # then close) to detect readiness, and that probe must not consume the + # one real connection. + tls = None + while tls is None: + conn, addr = listener.accept() + print(f'accepted connection from {addr}', flush=True) + try: + tls = context.wrap_socket(conn, server_side=True) + except (ssl.SSLError, OSError) as e: + print(f'TLS handshake failed (readiness probe?): {e}', flush=True) + conn.close() + tls = None + continue + print('TLS handshake complete', flush=True) + data = b'' + while b'\r\n\r\n' not in data: + chunk = tls.recv(4096) + if not chunk: + break + data += chunk + if b'\r\n\r\n' not in data: + print('peer closed before request headers arrived', flush=True) + tls.close() + tls = None + continue + print('request headers received; not reading the body', flush=True) + + # Let the proxy get deep into sending the request body, then abort. With + # SO_LINGER(0) the close discards buffered data and sends an RST; the + # unread body bytes in the receive queue force an RST as well. Do not + # unwrap()/shutdown the TLS layer: this is an abortive transport-level + # close, not a clean close_notify. + time.sleep(args.delay) + tls.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + tls.close() + print('connection reset sent', flush=True) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/gold_tests/tls/tls_record_size.test.py b/tests/gold_tests/tls/tls_record_size.test.py new file mode 100644 index 00000000000..b40c33a6fa5 --- /dev/null +++ b/tests/gold_tests/tls/tls_record_size.test.py @@ -0,0 +1,117 @@ +''' +Exercise the TLS record-size clamp (proxy.config.ssl.max_record_size > 0): on a +large TLS download the body must arrive intact and every application-data record +on the wire must be clamped to the configured size. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# NOTE: only the positive (fixed-clamp) branch of the record-sizing logic is +# covered here. The documented dynamic mode (max_record_size == -1) cannot be +# enabled through records.yaml because the record's validity check is [0-16383], +# which rejects -1; that inconsistency is pre-existing, so the dynamic branch +# stays uncovered by design. + +import os +import sys + +Test.Summary = __doc__ + + +class TestRecordSizeClamp: + '''Verify max_record_size clamps every record of a large TLS download.''' + + # Comfortably larger than the clamp so many records pass through it. + _body_len: int = 1024 * 1024 + _max_record: int = 4096 + + _server_counter: int = 0 + _ts_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + self._server = self._configure_server() + self._ts = self._configure_trafficserver() + + def _configure_server(self) -> 'Process': + '''Configure the origin server with a large response body. + + :return: The origin server Process. + ''' + server = Test.MakeOriginServer(f'server-{TestRecordSizeClamp._server_counter}') + TestRecordSizeClamp._server_counter += 1 + + body = "x" * TestRecordSizeClamp._body_len + request_header = {"headers": "GET /obj HTTP/1.1\r\nHost: ex.test\r\n\r\n", "timestamp": "1469733493.993", "body": ""} + response_header = { + "headers": + "HTTP/1.1 200 OK\r\nServer: microserver\r\nConnection: close\r\n" + f"Cache-Control: max-age=3600\r\nContent-Length: {TestRecordSizeClamp._body_len}\r\n\r\n", + "timestamp": "1469733493.993", + "body": body + } + server.addResponse("sessionlog.json", request_header, response_header) + return server + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server with a positive max_record_size clamp. + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestRecordSizeClamp._ts_counter}', enable_tls=True) + TestRecordSizeClamp._ts_counter += 1 + + ts.addDefaultSSLFiles() + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.Port}') + ts.Disk.records_config.update( + { + 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}', + 'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}', + # Positive cap -> the write path clamps each TLS record to this many bytes. + 'proxy.config.ssl.max_record_size': TestRecordSizeClamp._max_record, + }) + return ts + + def run(self) -> None: + '''Configure and run the TestRun. + + The client downloads the object and measures the TLS records on the wire, + asserting both that the body is intact and that no application-data record + exceeds the configured clamp. + ''' + tr = Test.AddTestRun("max_record_size>0 clamps records on a large TLS download") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Command = ( + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_record_size_client.py")} ' + f'-p {self._ts.Variables.ssl_port} --host ex.test --path /obj ' + f'--max-record {TestRecordSizeClamp._max_record} --expect-bytes {TestRecordSizeClamp._body_len}') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.All += Testers.ContainsExpression( + "PASS: every application-data record is within the configured clamp", + "every TLS record must be clamped to the configured size") + tr.StillRunningAfter = self._ts + tr.StillRunningAfter = self._server + + +TestRecordSizeClamp().run() diff --git a/tests/gold_tests/tls/tls_record_size_client.py b/tests/gold_tests/tls/tls_record_size_client.py new file mode 100644 index 00000000000..a169944e5c4 --- /dev/null +++ b/tests/gold_tests/tls/tls_record_size_client.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +''' +Download an object from ATS over TLS and inspect the TLS records on the wire: +confirm the body arrives intact AND that every application-data record is no +larger than the configured proxy.config.ssl.max_record_size (plus AEAD overhead). + +A MemoryBIO drives the handshake so the raw ciphertext stream is visible; the +5-byte TLS record headers (type, version, length) are in cleartext, so record +sizes can be measured without decrypting. TLS 1.2 is pinned so that handshake +messages are their own record type (22) and only real application data is type 23, +and the cipher is restricted to AEAD (GCM) suites so the per-record overhead is a +small fixed constant rather than the variable IV/MAC/padding of a CBC suite. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import socket +import ssl +import sys +from collections.abc import Iterator + +TLS_APPLICATION_DATA = 23 +# A clamped plaintext record becomes ciphertext of plaintext + AEAD overhead +# (TLS1.2 GCM: 8-byte explicit nonce + 16-byte tag = 24 bytes; the cipher is pinned +# to AEAD below). 256 is a generous ceiling over that, far below an unclamped ~16 KB +# record, so the clamp check stays decisive and cannot be tripped by the larger, +# variable expansion of a CBC suite. +RECORD_OVERHEAD = 256 + + +def iter_record_lengths(buf: bytes | bytearray) -> Iterator[tuple[int, int]]: + '''Yield (content_type, record_length) for each complete TLS record in buf.''' + i, n = 0, len(buf) + while i + 5 <= n: + content_type = buf[i] + length = (buf[i + 3] << 8) | buf[i + 4] + if i + 5 + length > n: + break # truncated trailing record + yield content_type, length + i += 5 + length + + +def main() -> int: + parser = argparse.ArgumentParser(description='Measure ATS TLS record sizes on a download.') + parser.add_argument('-p', '--port', type=int, required=True, help='ATS TLS port') + parser.add_argument('--host', default='ex.test', help='Host header / SNI') + parser.add_argument('--path', default='/obj', help='request path') + parser.add_argument('--max-record', type=int, required=True, help='configured proxy.config.ssl.max_record_size') + parser.add_argument('--expect-bytes', type=int, required=True, help='expected response body length') + args = parser.parse_args() + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.maximum_version = ssl.TLSVersion.TLSv1_2 + # Pin AEAD (GCM) suites: their per-record overhead is a small fixed 24 bytes, so a + # clamped record stays well within max_record + RECORD_OVERHEAD. A negotiated CBC + # suite could expand a record by IV + MAC + up to 255 bytes of padding and trip the + # clamp check spuriously. SECLEVEL=0 keeps the small test cert usable. + ctx.set_ciphers( + 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:' + 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:' + 'AES128-GCM-SHA256:AES256-GCM-SHA384@SECLEVEL=0') + + incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + tls = ctx.wrap_bio(incoming, outgoing, server_hostname=args.host) + + sock = socket.create_connection(('127.0.0.1', args.port), timeout=30) + sock.settimeout(30) + raw = bytearray() # every ciphertext byte the server sends, in order + + def flush() -> None: + data = outgoing.read() + if data: + sock.sendall(data) + + def feed() -> bytes: + chunk = sock.recv(65536) + if chunk: + raw.extend(chunk) + incoming.write(chunk) + return chunk + + while True: + try: + tls.do_handshake() + break + except ssl.SSLWantReadError: + flush() + if not feed(): + print('FAIL: server closed during handshake') + return 2 + flush() + + request = (f'GET {args.path} HTTP/1.1\r\n' + f'Host: {args.host}\r\n' + f'Connection: close\r\n\r\n').encode() + tls.write(request) + flush() + + response = bytearray() + while True: + try: + data = tls.read(65536) + if not data: + break # clean close_notify + response.extend(data) + except ssl.SSLWantReadError: + flush() + if not feed(): + break # socket closed by ATS + except ssl.SSLEOFError: + break + + separator = response.find(b'\r\n\r\n') + body_len = len(response) - (separator + 4) if separator >= 0 else -1 + + app_lengths = [length for content_type, length in iter_record_lengths(raw) if content_type == TLS_APPLICATION_DATA] + max_record = max(app_lengths) if app_lengths else 0 + limit = args.max_record + RECORD_OVERHEAD + + print( + f'app_data_records={len(app_lengths)} max_record_len={max_record} ' + f'limit={limit} body_len={body_len} expect={args.expect_bytes}') + + if body_len != args.expect_bytes: + print(f'FAIL: body length {body_len} != expected {args.expect_bytes}') + return 1 + if len(app_lengths) < 2: + print('FAIL: too few application-data records to judge clamping') + return 1 + if max_record > limit: + print(f'FAIL: an application-data record ({max_record}) exceeds the clamp + overhead ({limit})') + return 1 + print('PASS: every application-data record is within the configured clamp') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/gold_tests/tls/tls_reload_under_load.test.py b/tests/gold_tests/tls/tls_reload_under_load.test.py new file mode 100644 index 00000000000..1828da760d2 --- /dev/null +++ b/tests/gold_tests/tls/tls_reload_under_load.test.py @@ -0,0 +1,100 @@ +''' +Existing cert/SNI reload tests reload while the server is idle. This one drives +continuous concurrent TLS handshakes and reloads ssl_multicert.yaml on top of +them, stressing the SSL/BIO ownership boundary of the layered TLS VConnection. +The swapped-in certificate must take effect, every handshake must succeed, and +ATS must not crash. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +Test.Summary = __doc__ + + +class TestTlsReloadUnderLoad: + '''Reload TLS certs while concurrent handshakes are in flight; serving must not break.''' + + _ts_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + self._ts = self._configure_trafficserver() + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server with a swappable live certificate. + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestTlsReloadUnderLoad._ts_counter}', enable_tls=True, enable_cache=False) + TestTlsReloadUnderLoad._ts_counter += 1 + + # signed-bar is the live cert; signed2-bar shares its key and is copied over + # the live cert at reload time. Distinct fingerprints let the client prove the + # swap took effect under load. + ts.addSSLfile("ssl/signed-bar.pem") + ts.addSSLfile("ssl/signed-bar.key") + ts.addSSLfile("ssl/signed2-bar.pem") + + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: signed-bar.pem + ssl_key_name: signed-bar.key +""".split("\n")) + ts.Disk.records_config.update( + { + 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}', + 'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}', + 'proxy.config.exec_thread.autoconfig.scale': 1.0, + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'ssl', + }) + + # The reload must actually have run (otherwise the test would be vacuous). + ts.Disk.diags_log.Content = Testers.ContainsExpression( + "ssl_multicert.yaml finished loading", "the cert configuration must reload while load is in flight") + # The reload-under-load must not crash or trip an assertion / sanitizer. + ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + "received signal|failed assertion", "ATS must not crash reloading certs under load") + ts.Disk.traffic_out.Content += Testers.ExcludesExpression( + "AddressSanitizer|use-after-free|runtime error:", "no memory-safety error reloading certs under load") + return ts + + def run(self) -> None: + '''Configure and run the TestRun.''' + tr = Test.AddTestRun("reload certs while TLS handshakes are in flight") + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Command = ( + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_reload_under_load_client.py")} ' + f'-p {self._ts.Variables.ssl_port} --sni bar.com --ssldir {self._ts.Variables.SSLDir} ' + f'--live-cert signed-bar.pem ' + f'--v2-cert {os.path.join(self._ts.Variables.SSLDir, "signed2-bar.pem")} ' + f'--reloads 3 --duration 8 --concurrency 4') + # traffic_ctl (invoked by the client to reload) needs the runroot environment. + tr.Processes.Default.Env = self._ts.Env + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.All = Testers.ContainsExpression("RESULT=PASS", "load + reload run must pass") + tr.Processes.Default.Streams.All += Testers.ContainsExpression("FAILURES=0", "no handshake may fail across the reload") + tr.Processes.Default.Streams.All += Testers.ContainsExpression( + "CERT_CHANGED=1", "the swapped-in certificate must be served after the reload") + tr.StillRunningAfter = self._ts + + +TestTlsReloadUnderLoad().run() diff --git a/tests/gold_tests/tls/tls_reload_under_load_client.py b/tests/gold_tests/tls/tls_reload_under_load_client.py new file mode 100644 index 00000000000..c4ed0577a39 --- /dev/null +++ b/tests/gold_tests/tls/tls_reload_under_load_client.py @@ -0,0 +1,183 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Drive continuous concurrent TLS handshakes against ATS while a certificate +reload (traffic_ctl config reload) happens mid-flight. + +This stresses the SSL/BIO ownership boundary of the layered TLS VConnection: the +SSL context list is rebuilt while inbound handshakes and request/response writes +are in flight. A correct server keeps serving (no failed handshakes, no crash) +and the swapped-in certificate takes effect. + +The script is self-contained: worker threads hammer handshakes for the whole run +while the main thread swaps the cert file and reloads ATS at a known offset, so +the reload provably overlaps in-flight handshakes. +""" + +import argparse +import collections +import hashlib +import os +import shutil +import socket +import ssl +import subprocess +import sys +import threading +import time + + +def make_context() -> ssl.SSLContext: + # We validate handshake mechanics and cert identity (by fingerprint), not the + # trust chain, so disable verification to keep the client independent of which + # signer is currently installed. + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +class Stats: + + def __init__(self) -> None: + self.lock = threading.Lock() + self.ok = 0 + self.fail = 0 + self.errors: collections.Counter = collections.Counter() + self.pre_fps: collections.Counter = collections.Counter() + self.post_fps: collections.Counter = collections.Counter() + + +def worker( + args: argparse.Namespace, stats: Stats, stop: threading.Event, reload_started: threading.Event, + reload_settled: threading.Event) -> None: + ctx = make_context() + while not stop.is_set(): + try: + with socket.create_connection((args.host, args.port), timeout=args.timeout) as sock: + with ctx.wrap_socket(sock, server_hostname=args.sni) as ssock: + der = ssock.getpeercert(binary_form=True) + fp = hashlib.sha256(der).hexdigest()[:16] if der else "none" + request = (f"GET / HTTP/1.1\r\nHost: {args.sni}\r\n" + "Connection: close\r\n\r\n").encode() + ssock.sendall(request) + resp = ssock.recv(256) + if not resp.startswith(b"HTTP/1."): + with stats.lock: + stats.fail += 1 + stats.errors["bad_response"] += 1 + continue + with stats.lock: + stats.ok += 1 + # Bucket the served cert by phase. Ignore the transition window so + # pre==v1 and post==v2 cleanly, regardless of reload latency. + if not reload_started.is_set(): + stats.pre_fps[fp] += 1 + elif reload_settled.is_set(): + stats.post_fps[fp] += 1 + except Exception as exc: # noqa: BLE001 - any failure is a test signal + with stats.lock: + stats.fail += 1 + stats.errors[type(exc).__name__] += 1 + + +def do_reload(env_traffic_ctl: str) -> None: + subprocess.run([env_traffic_ctl, "config", "reload"], check=True, timeout=30) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("-p", "--port", type=int, required=True) + parser.add_argument("--sni", default="bar.com") + parser.add_argument("--ssldir", required=True, help="dir holding the live cert that gets swapped") + parser.add_argument("--live-cert", default="signed-bar.pem", help="cert filename inside --ssldir") + parser.add_argument("--v2-cert", required=True, help="source cert to copy over the live cert at reload") + parser.add_argument("--traffic-ctl", default="traffic_ctl") + parser.add_argument("--duration", type=float, default=8.0) + parser.add_argument("--concurrency", type=int, default=4) + parser.add_argument("--timeout", type=float, default=5.0) + parser.add_argument("--reload-at", type=float, default=2.0) + parser.add_argument("--reloads", type=int, default=3) + parser.add_argument("--reload-spacing", type=float, default=0.7) + parser.add_argument("--settle", type=float, default=0.8, help="grace after last reload before sampling post cert") + parser.add_argument("--min-ok", type=int, default=20) + args = parser.parse_args() + + stats = Stats() + stop = threading.Event() + reload_started = threading.Event() + reload_settled = threading.Event() + + workers = [ + threading.Thread(target=worker, args=(args, stats, stop, reload_started, reload_settled), daemon=True) + for _ in range(args.concurrency) + ] + start = time.monotonic() + for t in workers: + t.start() + + # Let load ramp up so the reload lands on top of in-flight handshakes. + time.sleep(args.reload_at) + + live_path = os.path.join(args.ssldir, args.live_cert) + reloads_done = 0 + reload_error = "" + try: + # Swap to v2 once; the first reload installs it. Further reloads keep + # rebuilding the SSL store under sustained load. + shutil.copyfile(args.v2_cert, live_path) + os.utime(live_path, None) + reload_started.set() + for i in range(args.reloads): + do_reload(args.traffic_ctl) + reloads_done += 1 + if i != args.reloads - 1: + time.sleep(args.reload_spacing) + except Exception as exc: # noqa: BLE001 + reload_error = f"{type(exc).__name__}: {exc}" + + time.sleep(args.settle) + reload_settled.set() + + elapsed = time.monotonic() - start + if args.duration > elapsed: + time.sleep(args.duration - elapsed) + stop.set() + for t in workers: + t.join(timeout=args.timeout + 2) + + pre_fp = stats.pre_fps.most_common(1)[0][0] if stats.pre_fps else "none" + post_fp = stats.post_fps.most_common(1)[0][0] if stats.post_fps else "none" + cert_changed = int(pre_fp != "none" and post_fp != "none" and pre_fp != post_fp) + + passed = ( + reload_error == "" and reloads_done == args.reloads and stats.fail == 0 and stats.ok >= args.min_ok and cert_changed == 1) + + print(f"HANDSHAKES_OK={stats.ok}") + print(f"FAILURES={stats.fail}") + print(f"RELOADS_DONE={reloads_done}") + print(f"CERT_CHANGED={cert_changed}") + print(f"PRE_FP={pre_fp} POST_FP={post_fp}") + if stats.errors: + print(f"ERROR_BREAKDOWN={dict(stats.errors)}") + if reload_error: + print(f"RELOAD_ERROR={reload_error}") + print(f"RESULT={'PASS' if passed else 'FAIL'}") + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/gold_tests/tls/tls_renegotiation.test.py b/tests/gold_tests/tls/tls_renegotiation.test.py new file mode 100644 index 00000000000..70fecc14df4 --- /dev/null +++ b/tests/gold_tests/tls/tls_renegotiation.test.py @@ -0,0 +1,124 @@ +''' +With client renegotiation disallowed (the default), a TLSv1.2 client that asks to +renegotiate must have its connection refused without taking down the server. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +Test.Summary = __doc__ + +# Renegotiation only exists in TLS 1.2 and earlier. +Test.SkipUnless(Condition.HasOpenSSLVersion("1.1.1"), Condition.HasLegacyTLSSupport()) + + +class TestRenegotiationRefused: + '''Verify a refused client-initiated renegotiation does not crash ATS.''' + + _server_counter: int = 0 + _ts_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + self._server = self._configure_server() + self._ts = self._configure_trafficserver() + + def _configure_server(self) -> 'Process': + '''Configure the origin server. + + :return: The origin server Process. + ''' + server = Test.MakeOriginServer(f'server-{TestRenegotiationRefused._server_counter}') + TestRenegotiationRefused._server_counter += 1 + + request_header = {"headers": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", "timestamp": "1469733493.993", "body": ""} + response_header = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "timestamp": "1469733493.993", "body": "ok"} + server.addResponse("sessionlog.json", request_header, response_header) + return server + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server to refuse client renegotiation (the default). + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestRenegotiationRefused._ts_counter}', enable_tls=True) + TestRenegotiationRefused._ts_counter += 1 + + ts.addSSLfile("ssl/server.pem") + ts.addSSLfile("ssl/server.key") + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + ts.Disk.records_config.update( + { + 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}', + 'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}', + # The secure default: never honor a client-initiated renegotiation. + 'proxy.config.ssl.allow_client_renegotiation': 0, + # Renegotiation requires a sub-TLS1.3 protocol. + 'proxy.config.ssl.TLSv1_3.enabled': 0, + 'proxy.config.ssl.TLSv1_2': 1, + # So the renegotiation-detection log line below is emitted. + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'ssl_load', + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.Port}') + + # The refused renegotiation must not abort the process. + ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + "received signal|failed assertion", "ATS must refuse the renegotiation without crashing") + # ...and it must actually reach the renegotiation-detection path (otherwise + # a no-crash pass could mean the client never managed to renegotiate at all). + ts.Disk.traffic_out.Content += Testers.ContainsExpression( + "trying to renegotiate from the client", "ATS must detect the client-initiated renegotiation") + return ts + + def run(self) -> None: + '''Configure and run the TestRuns.''' + # Complete a TLSv1.2 handshake, then ask to renegotiate. ATS must detect + # and refuse the renegotiation without aborting the process. + tr = Test.AddTestRun("client renegotiation is refused without crashing ATS") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Command = ( + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_renegotiation_client.py")} ' + f'-p {self._ts.Variables.ssl_port} -s example.com') + tr.Processes.Default.ReturnCode = 0 + tr.StillRunningAfter = self._ts + tr.StillRunningAfter = self._server + + # ATS survived, so a normal request still succeeds. + tr = Test.AddTestRun("server still serves requests after the refused renegotiation") + tr.MakeCurlCommand( + ( + "-v --http1.1 --tls-max 1.2 --tlsv1.2 --ciphers DEFAULT@SECLEVEL=0 -k " + f"--resolve 'example.com:{self._ts.Variables.ssl_port}:127.0.0.1' " + f"https://example.com:{self._ts.Variables.ssl_port}/"), + ts=self._ts) + tr.Processes.Default.ReturnCode = 0 + # curl -v writes the response status line to stderr. + tr.Processes.Default.Streams.stderr = Testers.ContainsExpression( + "HTTP/1.1 200 OK", "request after renegotiation should succeed") + tr.StillRunningAfter = self._ts + + +TestRenegotiationRefused().run() diff --git a/tests/gold_tests/tls/tls_renegotiation_allowed.test.py b/tests/gold_tests/tls/tls_renegotiation_allowed.test.py new file mode 100644 index 00000000000..a5f58ff3581 --- /dev/null +++ b/tests/gold_tests/tls/tls_renegotiation_allowed.test.py @@ -0,0 +1,145 @@ +''' +With client renegotiation allowed, a TLSv1.2 client that asks to renegotiate must +get a prompt answer from ATS (the renegotiation either completes or is refused +with an alert) rather than hanging. ATS must flush the SSL protocol output the +renegotiation generates; otherwise the answer is never sent and the client +stalls. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +from ports import get_port + +Test.Summary = __doc__ + +# Renegotiation only exists in TLS 1.2 and earlier. +Test.SkipUnless(Condition.HasOpenSSLVersion("1.1.1"), Condition.HasLegacyTLSSupport()) + + +class TestRenegotiationAllowed: + '''Verify an allowed client renegotiation is answered promptly, not stranded.''' + + _server_counter: int = 0 + _ts_counter: int = 0 + + def __init__(self) -> None: + '''Declare the test Processes.''' + self._server = self._configure_server() + self._ts = self._configure_trafficserver() + + def _configure_server(self) -> 'Process': + '''Configure a plain HTTP/1.0 origin that closes each connection. + + A python http.server origin answers each request on its own connection and + closes it, so ATS's client-facing TLS write face is fully drained between + requests. When the renegotiation ClientHello then arrives there is no + in-flight transport write to coincidentally carry the renegotiation answer + out -- exactly the condition under which a proxy that fails to flush its own + SSL protocol output would strand the answer and hang the client. (The + Proxy-Verifier microserver keeps the response connection in a state that + masks the stall, so it is unsuitable for this regression.) + + :return: The origin server Process. + ''' + server = Test.Processes.Process(f'server-{TestRenegotiationAllowed._server_counter}') + TestRenegotiationAllowed._server_counter += 1 + + origin_dir = os.path.join(Test.RunDirectory, "origin") + os.makedirs(origin_dir, exist_ok=True) + with open(os.path.join(origin_dir, "index.html"), "w") as f: + f.write("ok\n") + + origin_port = get_port(server, "http_port") + server.Command = f'{sys.executable} -m http.server {origin_port} --bind 127.0.0.1 --directory {origin_dir}' + server.Ready = When.PortOpenv4(origin_port) + # The server is a long-running process the harness terminates at the end of + # the test, so its exit status is whatever the signal leaves behind. + server.ReturnCode = Any(None, 0, -2, -15) + return server + + def _configure_trafficserver(self) -> 'Process': + '''Configure Traffic Server to honor client renegotiation. + + :return: The Traffic Server Process. + ''' + ts = Test.MakeATSProcess(f'ts-{TestRenegotiationAllowed._ts_counter}', enable_tls=True) + TestRenegotiationAllowed._ts_counter += 1 + + ts.addSSLfile("ssl/server.pem") + ts.addSSLfile("ssl/server.key") + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + ts.Disk.records_config.update( + { + 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}', + 'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}', + # The operator opts in: honor a client-initiated renegotiation. + 'proxy.config.ssl.allow_client_renegotiation': 1, + # Renegotiation requires a sub-TLS1.3 protocol. + 'proxy.config.ssl.TLSv1_3.enabled': 0, + 'proxy.config.ssl.TLSv1_2': 1, + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.http_port}') + + # The renegotiation must not abort the process. + ts.Disk.traffic_out.Content = Testers.ExcludesExpression( + "received signal|failed assertion", "ATS must service the renegotiation without crashing") + return ts + + def run(self) -> None: + '''Configure and run the TestRuns.''' + # Complete a TLSv1.2 handshake and one request, let the response drain, ask + # to renegotiate, then drive a second request. ATS must answer the + # renegotiation promptly; the helper reports RENEGOTIATION-STALLED (and + # exits non-zero) if the client hangs waiting for the answer. + tr = Test.AddTestRun("client renegotiation is answered promptly") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Command = ( + f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_renegotiation_allowed_client.py")} ' + f'-p {self._ts.Variables.ssl_port} -s example.com') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression( + "RENEGOTIATION-COMPLETED|RENEGOTIATION-REFUSED-PROMPTLY", "the renegotiation must be answered, not stranded") + tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression( + "RENEGOTIATION-STALLED", "the client must not hang waiting for the renegotiation answer") + tr.StillRunningAfter = self._ts + tr.StillRunningAfter = self._server + + # ATS survived and keeps serving, so a normal request still succeeds. + tr = Test.AddTestRun("server still serves requests after the renegotiation") + tr.MakeCurlCommand( + ( + "-v --http1.1 --tls-max 1.2 --tlsv1.2 --ciphers DEFAULT@SECLEVEL=0 -k " + f"--resolve 'example.com:{self._ts.Variables.ssl_port}:127.0.0.1' " + f"https://example.com:{self._ts.Variables.ssl_port}/"), + ts=self._ts) + tr.Processes.Default.ReturnCode = 0 + # curl -v writes the response status line to stderr. + tr.Processes.Default.Streams.stderr = Testers.ContainsExpression( + "HTTP/1.1 200 OK", "request after renegotiation should succeed") + tr.StillRunningAfter = self._ts + + +TestRenegotiationAllowed().run() diff --git a/tests/gold_tests/tls/tls_renegotiation_allowed_client.py b/tests/gold_tests/tls/tls_renegotiation_allowed_client.py new file mode 100644 index 00000000000..ea4711be049 --- /dev/null +++ b/tests/gold_tests/tls/tls_renegotiation_allowed_client.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +''' +Drive an OpenSSL s_client connection that completes a TLSv1.2 handshake and +then requests a client-initiated renegotiation, with ATS configured to allow +client renegotiation. Verifies that ATS answers the renegotiation attempt +promptly -- either by completing it (libraries that honor it) or by +delivering its refusal alert (OpenSSL 3.x never honors a client +renegotiation unless SSL_OP_ALLOW_CLIENT_RENEGOTIATION is set) -- instead of +stranding the TLS response in its write buffer and leaving the client +hanging. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import shlex +import subprocess +import sys +import threading +import time + + +class OutputCollector: + '''Accumulate a subprocess's merged output from a reader thread.''' + + def __init__(self, stream): + self._stream = stream + self._lock = threading.Lock() + self._data = b'' + self._thread = threading.Thread(target=self._read, daemon=True) + self._thread.start() + + def _read(self): + while True: + chunk = self._stream.read1(4096) + if not chunk: + break + with self._lock: + self._data += chunk + + def data(self) -> bytes: + with self._lock: + return self._data + + +def wait_for(predicate, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.1) + return predicate() + + +def main() -> int: + parser = argparse.ArgumentParser(description='Trigger a TLSv1.2 client renegotiation against ATS and require a prompt answer.') + parser.add_argument('-p', '--ats-port', type=int, dest='ats_port', required=True, help='ATS TLS port number') + parser.add_argument('-s', '--server-name', type=str, dest='sni', default='example.com', help='SNI server name') + args = parser.parse_args() + + # -tls1_2 forces a protocol that supports renegotiation (TLS 1.3 has none). + # No -quiet: it suppresses s_client's interpretation of the "R" command, so + # the renegotiation request would otherwise be sent as plain application data. + cmd = shlex.split( + f'openssl s_client -connect 127.0.0.1:{args.ats_port} -tls1_2 -servername {args.sni} -cipher DEFAULT@SECLEVEL=0') + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = OutputCollector(proc.stdout) + verdict = 'RENEGOTIATION-STALLED' + + try: + request = b'GET / HTTP/1.1\r\nHost: ' + args.sni.encode() + b'\r\n\r\n' + proc.stdin.write(request) + proc.stdin.flush() + if not wait_for(lambda: b'HTTP/1.1 200 OK' in output.data(), 10): + print('NO-FIRST-RESPONSE: the request before the renegotiation never completed') + proc.kill() + return 1 + + # Let the first response fully drain so the proxy's TLS write face goes + # idle: that is exactly when the renegotiation answer has no in-flight + # transport write to ride out on, so a layered proxy that fails to flush + # SSL protocol output on its own strands the answer and the client hangs. + time.sleep(2) + + # s_client interprets a line containing just "R" as a request to + # renegotiate the session. + proc.stdin.write(b'R\n') + proc.stdin.flush() + time.sleep(1) + # A second request provokes the client into resolving the + # renegotiation it started: it either completes it and sends the + # request, or it reads the server's refusal alert and exits. + try: + proc.stdin.write(request) + proc.stdin.flush() + except BrokenPipeError: + # The client already exited on the refusal alert. + pass + + def renegotiation_answered(): + return output.data().count(b'HTTP/1.1 200 OK') >= 2 or proc.poll() is not None + + if wait_for(renegotiation_answered, 10): + if output.data().count(b'HTTP/1.1 200 OK') >= 2: + verdict = 'RENEGOTIATION-COMPLETED' + else: + verdict = 'RENEGOTIATION-REFUSED-PROMPTLY' + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + sys.stdout.write(output.data().decode('utf-8', errors='replace')) + print(verdict) + return 0 if verdict != 'RENEGOTIATION-STALLED' else 1 + + +if __name__ == '__main__': + exit(main()) diff --git a/tests/gold_tests/tls/tls_renegotiation_client.py b/tests/gold_tests/tls/tls_renegotiation_client.py new file mode 100644 index 00000000000..46af21aef05 --- /dev/null +++ b/tests/gold_tests/tls/tls_renegotiation_client.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +''' +Drive an OpenSSL s_client connection that completes a TLSv1.2 handshake and +then requests a client-initiated renegotiation. Used to verify that Traffic +Server refuses the renegotiation without crashing. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import shlex +import subprocess +import sys +import time + + +def main() -> None: + parser = argparse.ArgumentParser(description='Trigger a TLSv1.2 client renegotiation against ATS.') + parser.add_argument('-p', '--ats-port', type=int, dest='ats_port', required=True, help='ATS TLS port number') + parser.add_argument('-s', '--server-name', type=str, dest='sni', default='example.com', help='SNI server name') + args = parser.parse_args() + + # -tls1_2 forces a protocol that supports renegotiation (TLS 1.3 has none). + # No -quiet: it suppresses s_client's interpretation of the "R" command, so + # the renegotiation request would otherwise be sent as plain application data. + cmd = shlex.split( + f'openssl s_client -connect 127.0.0.1:{args.ats_port} -tls1_2 -servername {args.sni} -cipher DEFAULT@SECLEVEL=0') + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + # Complete the handshake and one request, then send "R" on its own line: + # s_client interprets that as a request to renegotiate the session. + try: + proc.stdin.write(b'GET / HTTP/1.1\r\nHost: ' + args.sni.encode() + b'\r\n\r\n') + proc.stdin.flush() + time.sleep(2) + proc.stdin.write(b'R\n') + proc.stdin.flush() + time.sleep(2) + proc.stdin.write(b'GET / HTTP/1.1\r\nHost: ' + args.sni.encode() + b'\r\n\r\n') + proc.stdin.flush() + out, _ = proc.communicate(timeout=10) + except (subprocess.TimeoutExpired, BrokenPipeError): + proc.kill() + out, _ = proc.communicate() + + sys.stdout.write(out.decode('utf-8', errors='replace')) + # The client's own exit status is irrelevant: ATS legitimately tears down + # the connection on the refused renegotiation. The test asserts on ATS + # staying alive, not on this process. + exit(0) + + +if __name__ == '__main__': + main()