This is an automated email from the ASF dual-hosted git repository.

bneradt 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 a0ece04992 Allow dynamic TLS record sizing (#13416)
a0ece04992 is described below

commit a0ece04992e7c3b3ef8b06f9c135c68b360e0843
Author: Brian Neradt <[email protected]>
AuthorDate: Wed Jul 29 11:54:16 2026 -0500

    Allow dynamic TLS record sizing (#13416)
    
    The documented -1 value for proxy.config.ssl.max_record_size is
    incorrectly rejected by records validation, leaving dynamic TLS record
    sizing unreachable from records.yaml.
    
    This widens the accepted range to include the dynamic sentinel,
    clarifies the documented modes, and extends TLS wire-level coverage to
    verify the small-to-large record transition.
    
    Fixes: #13288
---
 doc/admin-guide/files/records.yaml.en.rst      |  5 +-
 src/records/RecordsConfig.cc                   |  2 +-
 tests/gold_tests/tls/tls_record_size.test.py   | 72 ++++++++++++++------------
 tests/gold_tests/tls/tls_record_size_client.py | 50 +++++++++++++++---
 4 files changed, 86 insertions(+), 43 deletions(-)

diff --git a/doc/admin-guide/files/records.yaml.en.rst 
b/doc/admin-guide/files/records.yaml.en.rst
index fdaa7fc059..47d28e4a46 100644
--- a/doc/admin-guide/files/records.yaml.en.rst
+++ b/doc/admin-guide/files/records.yaml.en.rst
@@ -4316,8 +4316,9 @@ SSL Termination
   This configuration specifies the maximum number of bytes to write
   into a SSL record when replying over a SSL session. In some
   circumstances this setting can improve response latency by reducing
-  buffering at the SSL layer. This setting can have a value between 0
-  and 16383 (max TLS record size).
+  buffering at the SSL layer. This setting accepts ``-1`` for dynamic
+  sizing, ``0`` for the default behavior, or a fixed maximum between
+  ``1`` and ``16383`` bytes.
 
   The default of ``0`` means to always write all available data into
   a single SSL record.
diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc
index 4fa574d00b..003e494380 100644
--- a/src/records/RecordsConfig.cc
+++ b/src/records/RecordsConfig.cc
@@ -1230,7 +1230,7 @@ static constexpr RecordElement RecordsConfig[] =
   ,
   {RECT_CONFIG, "proxy.config.ssl.origin_session_cache.size", RECD_INT, 
"10240", RECU_RESTART_TS, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
   ,
-  {RECT_CONFIG, "proxy.config.ssl.max_record_size", RECD_INT, "0", 
RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-16383]", RECA_NULL}
+  {RECT_CONFIG, "proxy.config.ssl.max_record_size", RECD_INT, "0", 
RECU_DYNAMIC, RR_NULL, RECC_INT, "[-1-16383]", RECA_NULL}
   ,
   {RECT_CONFIG, "proxy.config.ssl.hsts_max_age", RECD_INT, "-1", RECU_DYNAMIC, 
RR_NULL, RECC_STR, "^-?[0-9]+$", RECA_NULL}
   ,
diff --git a/tests/gold_tests/tls/tls_record_size.test.py 
b/tests/gold_tests/tls/tls_record_size.test.py
index b40c33a6fa..9b49707953 100644
--- a/tests/gold_tests/tls/tls_record_size.test.py
+++ b/tests/gold_tests/tls/tls_record_size.test.py
@@ -1,7 +1,7 @@
 '''
-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.
+Exercise fixed and dynamic TLS record sizing. On a large TLS download the body
+must arrive intact and application-data records on the wire must follow the
+configured sizing strategy.
 '''
 #  Licensed to the Apache Software Foundation (ASF) under one
 #  or more contributor license agreements.  See the NOTICE file
@@ -19,30 +19,22 @@ on the wire must be clamped to the configured size.
 #  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
+class TestRecordSize:
+    '''Verify fixed and dynamic TLS record sizing on large downloads.'''
 
     _server_counter: int = 0
     _ts_counter: int = 0
 
-    def __init__(self) -> None:
+    def __init__(self, max_record: int, body_len: int) -> None:
         '''Declare the test Processes.'''
+        self._max_record = max_record
+        self._body_len = body_len
         self._server = self._configure_server()
         self._ts = self._configure_trafficserver()
 
@@ -51,15 +43,15 @@ class TestRecordSizeClamp:
 
         :return: The origin server Process.
         '''
-        server = 
Test.MakeOriginServer(f'server-{TestRecordSizeClamp._server_counter}')
-        TestRecordSizeClamp._server_counter += 1
+        server = 
Test.MakeOriginServer(f'server-{TestRecordSize._server_counter}')
+        TestRecordSize._server_counter += 1
 
-        body = "x" * TestRecordSizeClamp._body_len
+        body = "x" * self._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",
+                f"Cache-Control: max-age=3600\r\nContent-Length: 
{self._body_len}\r\n\r\n",
             "timestamp": "1469733493.993",
             "body": body
         }
@@ -67,12 +59,12 @@ class TestRecordSizeClamp:
         return server
 
     def _configure_trafficserver(self) -> 'Process':
-        '''Configure Traffic Server with a positive max_record_size clamp.
+        '''Configure Traffic Server with the requested record-size strategy.
 
         :return: The Traffic Server Process.
         '''
-        ts = Test.MakeATSProcess(f'ts-{TestRecordSizeClamp._ts_counter}', 
enable_tls=True)
-        TestRecordSizeClamp._ts_counter += 1
+        ts = Test.MakeATSProcess(f'ts-{TestRecordSize._ts_counter}', 
enable_tls=True)
+        TestRecordSize._ts_counter += 1
 
         ts.addDefaultSSLFiles()
         ts.Disk.ssl_multicert_yaml.AddLines(
@@ -87,31 +79,45 @@ ssl_multicert:
             {
                 '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,
+                'proxy.config.ssl.max_record_size': self._max_record,
             })
+        if self._max_record == -1:
+            ts.Disk.traffic_out.Content = Testers.ExcludesExpression(
+                r'proxy\.config\.ssl\.max_record_size.*Validity Check error',
+                'The dynamic record-size sentinel should pass records 
validation')
         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.
+        The client downloads the object and measures the TLS records on the 
wire.
         '''
-        tr = Test.AddTestRun("max_record_size>0 clamps records on a large TLS 
download")
+        if self._max_record == -1:
+            description = 'max_record_size=-1 dynamically sizes records on a 
large TLS download'
+            client_option = '--dynamic'
+            expected_output = 'PASS: TLS records ramp from small to large 
after the dynamic threshold'
+        else:
+            description = 'max_record_size>0 clamps records on a large TLS 
download'
+            client_option = f'--max-record {self._max_record}'
+            expected_output = 'PASS: every application-data record is within 
the configured clamp'
+
+        tr = Test.AddTestRun(description)
         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}')
+            f'{client_option} --expect-bytes {self._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")
+            expected_output, 'TLS records must follow the configured sizing 
strategy')
         tr.StillRunningAfter = self._ts
         tr.StillRunningAfter = self._server
 
 
-TestRecordSizeClamp().run()
+# The fixed-size test response is comfortably larger than its 4,096-byte clamp,
+# ensuring that many records exercise the clamp. The dynamic-sizing test
+# response must exceed its 1,000,000-byte threshold by enough data to 
demonstrate
+# both phases.
+TestRecordSize(4096, 1024 * 1024).run()
+TestRecordSize(-1, 2 * 1024 * 1024).run()
diff --git a/tests/gold_tests/tls/tls_record_size_client.py 
b/tests/gold_tests/tls/tls_record_size_client.py
index a169944e5c..b5a5ddce2c 100644
--- a/tests/gold_tests/tls/tls_record_size_client.py
+++ b/tests/gold_tests/tls/tls_record_size_client.py
@@ -1,8 +1,8 @@
 #!/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).
+confirm the body arrives intact AND that application-data records follow the
+configured fixed or dynamic record-size strategy.
 
 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
@@ -34,12 +34,16 @@ import sys
 from collections.abc import Iterator
 
 TLS_APPLICATION_DATA = 23
+TLS12_GCM_OVERHEAD = 24
 # 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
+DYNAMIC_SMALL_RECORD = 1300
+DYNAMIC_MAX_RECORD = 16383
+DYNAMIC_BYTE_THRESHOLD = 1_000_000
 
 
 def iter_record_lengths(buf: bytes | bytearray) -> Iterator[tuple[int, int]]:
@@ -54,12 +58,42 @@ def iter_record_lengths(buf: bytes | bytearray) -> 
Iterator[tuple[int, int]]:
         i += 5 + length
 
 
+def verify_dynamic_records(app_lengths: list[int]) -> bool:
+    '''Verify records ramp from single-segment to maximum-sized records.'''
+    small_limit = DYNAMIC_SMALL_RECORD + TLS12_GCM_OVERHEAD
+    max_limit = DYNAMIC_MAX_RECORD + TLS12_GCM_OVERHEAD
+    first_large = next((i for i, length in enumerate(app_lengths) if length > 
small_limit), None)
+
+    if first_large is None:
+        print('FAIL: dynamic sizing never ramped up to large TLS records')
+        return False
+
+    plaintext_before_ramp = sum(length - TLS12_GCM_OVERHEAD for length in 
app_lengths[:first_large])
+    if plaintext_before_ramp < DYNAMIC_BYTE_THRESHOLD:
+        print(
+            f'FAIL: dynamic sizing ramped after only {plaintext_before_ramp} 
plaintext bytes; '
+            f'expected at least {DYNAMIC_BYTE_THRESHOLD}')
+        return False
+
+    max_record = max(app_lengths)
+    if max_record > max_limit:
+        print(f'FAIL: a dynamic application-data record ({max_record}) exceeds 
the maximum ({max_limit})')
+        return False
+
+    print(
+        f'PASS: TLS records ramp from small to large after the dynamic 
threshold '
+        f'(first_large={first_large}, 
plaintext_before_ramp={plaintext_before_ramp}, max_record_len={max_record})')
+    return True
+
+
 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')
+    sizing = parser.add_mutually_exclusive_group(required=True)
+    sizing.add_argument('--max-record', type=int, help='positive 
proxy.config.ssl.max_record_size clamp')
+    sizing.add_argument('--dynamic', action='store_true', help='expect dynamic 
TLS record sizing')
     parser.add_argument('--expect-bytes', type=int, required=True, 
help='expected response body length')
     args = parser.parse_args()
 
@@ -132,11 +166,8 @@ def main() -> int:
 
     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}')
+    print(f'app_data_records={len(app_lengths)} max_record_len={max_record} 
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}')
@@ -144,6 +175,11 @@ def main() -> int:
     if len(app_lengths) < 2:
         print('FAIL: too few application-data records to judge clamping')
         return 1
+    if args.dynamic:
+        return 0 if verify_dynamic_records(app_lengths) else 1
+
+    assert args.max_record is not None
+    limit = args.max_record + RECORD_OVERHEAD
     if max_record > limit:
         print(f'FAIL: an application-data record ({max_record}) exceeds the 
clamp + overhead ({limit})')
         return 1

Reply via email to