This is an automated email from the ASF dual-hosted git repository.
moonchen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 3ce2acfbc6 tests: add TLS gold tests (#13306)
3ce2acfbc6 is described below
commit 3ce2acfbc6cf287e49638c4b054f48711c09a7e7
Author: Mo Chen <[email protected]>
AuthorDate: Thu Jun 25 11:59:38 2026 -0500
tests: add TLS gold tests (#13306)
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 +++++++++++++
tests/gold_tests/tls/tls_flow_control_client.py | 100 +++++++++++
.../gold_tests/tls/tls_origin_open_failed.test.py | 78 +++++++++
tests/gold_tests/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 +++++++++++++
tests/gold_tests/tls/tls_record_size_client.py | 155 +++++++++++++++++
tests/gold_tests/tls/tls_reload_under_load.test.py | 100 +++++++++++
.../gold_tests/tls/tls_reload_under_load_client.py | 183 +++++++++++++++++++++
tests/gold_tests/tls/tls_renegotiation.test.py | 124 ++++++++++++++
.../tls/tls_renegotiation_allowed.test.py | 145 ++++++++++++++++
.../tls/tls_renegotiation_allowed_client.py | 132 +++++++++++++++
tests/gold_tests/tls/tls_renegotiation_client.py | 67 ++++++++
15 files changed, 1657 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 333da8ba1f..e045c3c648 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 0000000000..f1da922182
--- /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 0000000000..66565c1912
--- /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 0000000000..52092aadf0
--- /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 0000000000..4d989a26ef
--- /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 0000000000..afd02385a1
--- /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 '<none>'
+ 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
"<none>"}')
+ 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 0000000000..8300358a31
--- /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 0000000000..b40c33a6fa
--- /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 0000000000..a169944e5c
--- /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 0000000000..1828da760d
--- /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 0000000000..c4ed0577a3
--- /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 0000000000..70fecc14df
--- /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 0000000000..a5f58ff358
--- /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 0000000000..ea4711be04
--- /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 0000000000..46af21aef0
--- /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()