From 2e3687f428269a6ef7bb5b79884f917a95f448b0 Mon Sep 17 00:00:00 2001 From: bneradt Date: Fri, 20 Mar 2026 12:34:23 -0500 Subject: [PATCH 1/2] Add sni.yaml session ticket overrides Add ssl_ticket_enabled and ssl_ticket_number as sni.yaml overrides, apply them during SNI handling so they affect TLS 1.2 resumption and TLS 1.3 ticket issuance, and add unit and AuTest coverage plus docs and sample config updates. Fixes #12953 --- configs/sni.yaml.default | 4 + doc/admin-guide/files/sni.yaml.en.rst | 11 + include/iocore/net/TLSSNISupport.h | 2 + include/iocore/net/YamlSNIConfig.h | 4 + src/iocore/net/SNIActionPerformer.cc | 34 +++ src/iocore/net/SNIActionPerformer.h | 30 ++ src/iocore/net/SSLUtils.cc | 101 ++++++- src/iocore/net/TLSSNISupport.cc | 1 + src/iocore/net/YamlSNIConfig.cc | 14 + src/iocore/net/unit_tests/sni_conf_test.yaml | 5 + .../net/unit_tests/test_SSLSNIConfig.cc | 7 + .../net/unit_tests/test_YamlSNIConfig.cc | 11 +- tests/gold_tests/tls/tls_sni_ticket.test.py | 264 ++++++++++++++++++ 13 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 tests/gold_tests/tls/tls_sni_ticket.test.py diff --git a/configs/sni.yaml.default b/configs/sni.yaml.default index c39363a6ef2..ec7a94c39be 100644 --- a/configs/sni.yaml.default +++ b/configs/sni.yaml.default @@ -28,6 +28,10 @@ # The location of the certificate file is relative to proxy.config.ssl.server.cert.path directory. # client_key - sets the file containing the client private key that corresponds to the certificate for the outbound connection. # client_sni_policy - policy of SNI on outbound connection. +# ssl_ticket_enabled - enables or disables session tickets for matched inbound TLS connections; parameters = 1 or 0. +# This overrides proxy.config.ssl.server.session_ticket.enable. +# ssl_ticket_number - sets the number of TLSv1.3 session tickets issued for matched inbound TLS connections; +# parameters = INTEGER. This overrides proxy.config.ssl.server.session_ticket.number. # http2 - adds or removes HTTP/2 (H2) from the protocol list advertised by ATS; parameter required = None, parameters = on or off # tunnel_route - sets the e2e tunnel route # forward_route - destination as an FQDN and port, separated by a colon :. diff --git a/doc/admin-guide/files/sni.yaml.en.rst b/doc/admin-guide/files/sni.yaml.en.rst index 95958943630..80489987cfe 100644 --- a/doc/admin-guide/files/sni.yaml.en.rst +++ b/doc/admin-guide/files/sni.yaml.en.rst @@ -172,6 +172,17 @@ server_groups_list Inbound Specifies an override to the `OpenSSL SSL_CTX_set_groups_list `_ documentation. +ssl_ticket_enabled Inbound Specifies an override to the global + :ts:cv:`proxy.config.ssl.server.session_ticket.enable` + :file:`records.yaml` configuration. Set this to :code:`1` to enable + session tickets or :code:`0` to disable them for matching inbound TLS + connections. + +ssl_ticket_number Inbound Specifies an override to the global + :ts:cv:`proxy.config.ssl.server.session_ticket.number` + :file:`records.yaml` configuration. This controls how many TLSv1.3 + session tickets are issued for matching inbound TLS connections. + host_sni_policy Inbound One of the values :code:`DISABLED`, :code:`PERMISSIVE`, or :code:`ENFORCED`. If not specified, the value of :ts:cv:`proxy.config.http.host_sni_policy` is used. diff --git a/include/iocore/net/TLSSNISupport.h b/include/iocore/net/TLSSNISupport.h index 22f3d751a8b..346c2c1c0af 100644 --- a/include/iocore/net/TLSSNISupport.h +++ b/include/iocore/net/TLSSNISupport.h @@ -140,6 +140,8 @@ class TLSSNISupport std::optional http2_max_priority_frames_per_minute; std::optional http2_max_rst_stream_frames_per_minute; std::optional http2_max_continuation_frames_per_minute; + std::optional ssl_ticket_enabled; + std::optional ssl_ticket_number; std::optional outbound_sni_policy; } hints_from_sni; diff --git a/include/iocore/net/YamlSNIConfig.h b/include/iocore/net/YamlSNIConfig.h index c8396a76e74..91d0683f8f3 100644 --- a/include/iocore/net/YamlSNIConfig.h +++ b/include/iocore/net/YamlSNIConfig.h @@ -59,6 +59,8 @@ TSDECL(client_sni_policy); TSDECL(server_cipher_suite); TSDECL(server_TLSv1_3_cipher_suites); TSDECL(server_groups_list); +TSDECL(ssl_ticket_enabled); +TSDECL(ssl_ticket_number); TSDECL(ip_allow); TSDECL(valid_tls_versions_in); TSDECL(valid_tls_version_min_in); @@ -107,6 +109,8 @@ struct YamlSNIConfig { std::string server_cipher_suite; std::string server_TLSv1_3_cipher_suites; std::string server_groups_list; + std::optional ssl_ticket_enabled; + std::optional ssl_ticket_number; std::string ip_allow; bool protocol_unset = true; unsigned long protocol_mask; diff --git a/src/iocore/net/SNIActionPerformer.cc b/src/iocore/net/SNIActionPerformer.cc index d0ea144f18a..a5c04710eb1 100644 --- a/src/iocore/net/SNIActionPerformer.cc +++ b/src/iocore/net/SNIActionPerformer.cc @@ -475,6 +475,40 @@ ServerMaxEarlyData::SNIAction([[maybe_unused]] SSL &ssl, const Context & /* ctx return SSL_TLSEXT_ERR_OK; } +int +ServerSessionTicketEnabled::SNIAction(SSL &ssl, const Context & /* ctx ATS_UNUSED */) const +{ +#if TS_HAS_TLS_SESSION_TICKET + if (auto snis = TLSSNISupport::getInstance(&ssl)) { + const char *servername = snis->get_sni_server_name(); + Dbg(dbg_ctl_ssl_sni, "Setting session ticket support from sni.yaml to %d for fqdn [%s]", session_ticket_enabled, servername); + snis->hints_from_sni.ssl_ticket_enabled = session_ticket_enabled; + } + + // Apply the ticket enable/disable flag immediately so the current handshake + // sees the per-SNI override before TLS session ticket processing kicks in. + if (session_ticket_enabled != 0) { + SSL_clear_options(&ssl, SSL_OP_NO_TICKET); + } else { + SSL_set_options(&ssl, SSL_OP_NO_TICKET); + } +#endif + return SSL_TLSEXT_ERR_OK; +} + +int +ServerSessionTicketNumber::SNIAction(SSL &ssl, const Context & /* ctx ATS_UNUSED */) const +{ +#if TS_HAS_TLS_SESSION_TICKET + if (auto snis = TLSSNISupport::getInstance(&ssl)) { + const char *servername = snis->get_sni_server_name(); + Dbg(dbg_ctl_ssl_sni, "Setting session ticket count from sni.yaml to %d for fqdn [%s]", session_ticket_number, servername); + snis->hints_from_sni.ssl_ticket_number = session_ticket_number; + } +#endif + return SSL_TLSEXT_ERR_OK; +} + int ServerCipherSuite::SNIAction(SSL &ssl, const Context & /* ctx ATS_UNUSED */) const { diff --git a/src/iocore/net/SNIActionPerformer.h b/src/iocore/net/SNIActionPerformer.h index c173caacaad..6f0e328333c 100644 --- a/src/iocore/net/SNIActionPerformer.h +++ b/src/iocore/net/SNIActionPerformer.h @@ -313,6 +313,36 @@ class ServerMaxEarlyData : public ActionItem #endif }; +/** + Override session ticket support by ssl_ticket_enabled in sni.yaml + */ +class ServerSessionTicketEnabled : public ActionItem +{ +public: + ServerSessionTicketEnabled(int value) : session_ticket_enabled(value) {} + ~ServerSessionTicketEnabled() override {} + + int SNIAction(SSL &ssl, const Context &ctx) const override; + +private: + int session_ticket_enabled = 0; +}; + +/** + Override the number of issued TLSv1.3 session tickets by ssl_ticket_number in sni.yaml + */ +class ServerSessionTicketNumber : public ActionItem +{ +public: + ServerSessionTicketNumber(int value) : session_ticket_number(value) {} + ~ServerSessionTicketNumber() override {} + + int SNIAction(SSL &ssl, const Context &ctx) const override; + +private: + int session_ticket_number = 0; +}; + /** Override proxy.config.ssl.server.cipher_suite by server_cipher_suite in sni.yaml */ diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index 16b984cd737..9bca4c30869 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -97,6 +97,12 @@ static DbgCtl dbg_ctl_ssl_session_cache{"ssl.session_cache"}; static DbgCtl dbg_ctl_ssl_error{"ssl.error"}; static DbgCtl dbg_ctl_ssl_verify{"ssl_verify"}; +#if TS_HAS_TLS_SESSION_TICKET +static bool ssl_context_enable_ticket_callback(SSL_CTX *ctx); +static bool ssl_apply_sni_session_ticket_properties(SSL *ssl); +static bool ssl_set_session_ticket_number(SSL *ssl, size_t num_tickets); +#endif + /* Using pthread thread ID and mutex functions directly, instead of * ATS this_ethread / ProxyMutex, so that other linked libraries * may use pthreads and openssl without confusing us here. (TS-2271). @@ -304,15 +310,8 @@ ssl_cert_callback(SSL *ssl, [[maybe_unused]] void *arg) setClientCertCACerts(ssl, sslnetvc->get_ca_cert_file(), sslnetvc->get_ca_cert_dir()); } - // Reset the ticket callback if needed - SSL_CTX *ctx = SSL_get_SSL_CTX(ssl); - shared_SSLMultiCertConfigParams sslMultiCertSettings = std::make_shared(); - if (sslMultiCertSettings->session_ticket_enabled != 0) { -#ifdef HAVE_SSL_CTX_SET_TLSEXT_TICKET_KEY_EVP_CB - SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ssl_callback_session_ticket); -#else - SSL_CTX_set_tlsext_ticket_key_cb(ctx, ssl_callback_session_ticket); -#endif + if (!ssl_apply_sni_session_ticket_properties(ssl)) { + retval = 0; } } #endif @@ -493,6 +492,77 @@ ssl_context_enable_dhe(const char *dhparams_file, SSL_CTX *ctx) return ctx; } +#if TS_HAS_TLS_SESSION_TICKET +static bool +ssl_context_enable_ticket_callback(SSL_CTX *ctx) +{ +#ifdef HAVE_SSL_CTX_SET_TLSEXT_TICKET_KEY_EVP_CB + if (SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ssl_callback_session_ticket) == 0) { +#else + if (SSL_CTX_set_tlsext_ticket_key_cb(ctx, ssl_callback_session_ticket) == 0) { +#endif + Error("failed to set session ticket callback"); + return false; + } + return true; +} + +static bool +ssl_set_session_ticket_number(SSL *ssl, size_t num_tickets) +{ +#if defined(OPENSSL_IS_BORINGSSL) + // BoringSSL only exposes SSL_CTX_set_num_tickets(), so the per-connection + // sni.yaml override is not available here. + (void)ssl; + (void)num_tickets; + return true; +#else + return SSL_set_num_tickets(ssl, num_tickets) == 1; +#endif +} + +static bool +ssl_apply_sni_session_ticket_properties(SSL *ssl) +{ + auto snis = TLSSNISupport::getInstance(ssl); + if (snis == nullptr) { + return true; + } + + auto const &hints = snis->hints_from_sni; + if (!hints.ssl_ticket_enabled.has_value() && !hints.ssl_ticket_number.has_value()) { + return true; + } + + std::optional num_tickets; + + if (hints.ssl_ticket_enabled.has_value()) { + if (hints.ssl_ticket_enabled.value() != 0) { + SSL_clear_options(ssl, SSL_OP_NO_TICKET); + Dbg(dbg_ctl_ssl_load, "Enabled session tickets due to sni.yaml override"); + } else { + SSL_set_options(ssl, SSL_OP_NO_TICKET); + num_tickets = 0; + Dbg(dbg_ctl_ssl_load, "Disabled session tickets due to sni.yaml override"); + } + } + + if ((!hints.ssl_ticket_enabled.has_value() || hints.ssl_ticket_enabled.value() != 0) && hints.ssl_ticket_number.has_value()) { + num_tickets = hints.ssl_ticket_number.value() > 0 ? static_cast(hints.ssl_ticket_number.value()) : 0; + } + + if (num_tickets.has_value()) { + if (!ssl_set_session_ticket_number(ssl, num_tickets.value())) { + Error("failed to set session ticket number from sni.yaml"); + return false; + } + Dbg(dbg_ctl_ssl_load, "Set session ticket number from sni.yaml to %zu", num_tickets.value()); + } + + return true; +} +#endif + static ssl_ticket_key_block * ssl_context_enable_tickets(SSL_CTX *ctx, const char *ticket_key_path) { @@ -509,12 +579,7 @@ ssl_context_enable_tickets(SSL_CTX *ctx, const char *ticket_key_path) // Setting the callback can only fail if OpenSSL does not recognize the // SSL_CTRL_SET_TLSEXT_TICKET_KEY_CB constant. we set the callback first // so that we don't leave a ticket_key pointer attached if it fails. -#ifdef HAVE_SSL_CTX_SET_TLSEXT_TICKET_KEY_EVP_CB - if (SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ssl_callback_session_ticket) == 0) { -#else - if (SSL_CTX_set_tlsext_ticket_key_cb(ctx, ssl_callback_session_ticket) == 0) { -#endif - Error("failed to set session ticket callback"); + if (!ssl_context_enable_ticket_callback(ctx)) { ticket_block_free(keyblock); return nullptr; } @@ -1179,6 +1244,12 @@ SSLMultiCertConfigLoader::init_server_ssl_ctx(CertLoadData const &data, const SS } } +#if TS_HAS_TLS_SESSION_TICKET + if (!ssl_context_enable_ticket_callback(ctx)) { + goto fail; + } +#endif + if (!this->_setup_client_cert_verification(ctx)) { goto fail; } diff --git a/src/iocore/net/TLSSNISupport.cc b/src/iocore/net/TLSSNISupport.cc index 9b56c9129be..b6acd202b2b 100644 --- a/src/iocore/net/TLSSNISupport.cc +++ b/src/iocore/net/TLSSNISupport.cc @@ -165,6 +165,7 @@ TLSSNISupport::set_sni_server_name(SSL *ssl, char const *name) void TLSSNISupport::_clear() { + hints_from_sni = {}; _sni_server_name.reset(); } diff --git a/src/iocore/net/YamlSNIConfig.cc b/src/iocore/net/YamlSNIConfig.cc index bbc0eb4ace0..db3049655d5 100644 --- a/src/iocore/net/YamlSNIConfig.cc +++ b/src/iocore/net/YamlSNIConfig.cc @@ -164,6 +164,12 @@ YamlSNIConfig::Item::populate_sni_actions(action_vector_t &actions) if (!server_groups_list.empty()) { actions.push_back(std::make_unique(server_groups_list)); } + if (ssl_ticket_enabled.has_value()) { + actions.push_back(std::make_unique(ssl_ticket_enabled.value())); + } + if (ssl_ticket_number.has_value()) { + actions.push_back(std::make_unique(ssl_ticket_number.value())); + } if (http2_buffer_water_mark.has_value()) { actions.push_back(std::make_unique(http2_buffer_water_mark.value())); } @@ -230,6 +236,8 @@ std::set valid_sni_config_keys = {TS_fqdn, TS_server_TLSv1_3_cipher_suites, #endif TS_server_groups_list, + TS_ssl_ticket_enabled, + TS_ssl_ticket_number, TS_http2, TS_http2_buffer_water_mark, TS_http2_initial_window_size_in, @@ -465,6 +473,12 @@ template <> struct convert { if (node[TS_server_groups_list]) { item.server_groups_list = node[TS_server_groups_list].as(); } + if (node[TS_ssl_ticket_enabled]) { + item.ssl_ticket_enabled = node[TS_ssl_ticket_enabled].as(); + } + if (node[TS_ssl_ticket_number]) { + item.ssl_ticket_number = node[TS_ssl_ticket_number].as(); + } if (node[TS_ip_allow]) { item.ip_allow = node[TS_ip_allow].as(); } diff --git a/src/iocore/net/unit_tests/sni_conf_test.yaml b/src/iocore/net/unit_tests/sni_conf_test.yaml index 2f7a1dedc39..487daf8070c 100644 --- a/src/iocore/net/unit_tests/sni_conf_test.yaml +++ b/src/iocore/net/unit_tests/sni_conf_test.yaml @@ -49,3 +49,8 @@ sni: # test glob in the middle, this will be an exact match - fqdn: "cat.*.com" + +# test session ticket overrides +- fqdn: tickets.com + ssl_ticket_enabled: 1 + ssl_ticket_number: 3 diff --git a/src/iocore/net/unit_tests/test_SSLSNIConfig.cc b/src/iocore/net/unit_tests/test_SSLSNIConfig.cc index 19fbb31c3b9..382c6aab7f7 100644 --- a/src/iocore/net/unit_tests/test_SSLSNIConfig.cc +++ b/src/iocore/net/unit_tests/test_SSLSNIConfig.cc @@ -107,6 +107,13 @@ TEST_CASE("Test SSLSNIConfig") REQUIRE(actions.first->size() == 3); } + SECTION("The config matches an SNI for tickets.com") + { + auto const &actions{params.get("tickets.com", 443)}; + REQUIRE(actions.first); + REQUIRE(actions.first->size() == 4); ///< ticket enabled + ticket number + early data + fqdn + } + SECTION("Matching order") { auto const &actions{params.get("foo.bar.com", 443)}; diff --git a/src/iocore/net/unit_tests/test_YamlSNIConfig.cc b/src/iocore/net/unit_tests/test_YamlSNIConfig.cc index 14327bb484b..7f22739e6b6 100644 --- a/src/iocore/net/unit_tests/test_YamlSNIConfig.cc +++ b/src/iocore/net/unit_tests/test_YamlSNIConfig.cc @@ -56,7 +56,7 @@ TEST_CASE("YamlSNIConfig sets port ranges appropriately") FAIL(errorstream.str()); } REQUIRE(zret.is_ok()); - REQUIRE(conf.items.size() == 10); + REQUIRE(conf.items.size() == 11); SECTION("If no ports were specified, port range should contain all ports.") { @@ -103,6 +103,15 @@ TEST_CASE("YamlSNIConfig sets port ranges appropriately") { CHECK(conf.items[2].inbound_port_ranges.size() == 1); } + + SECTION("Session ticket overrides are parsed.") + { + auto const &item{conf.items[10]}; + REQUIRE(item.ssl_ticket_enabled.has_value()); + CHECK(item.ssl_ticket_enabled.value() == 1); + REQUIRE(item.ssl_ticket_number.has_value()); + CHECK(item.ssl_ticket_number.value() == 3); + } } TEST_CASE("YamlConfig handles bad ports appropriately.") diff --git a/tests/gold_tests/tls/tls_sni_ticket.test.py b/tests/gold_tests/tls/tls_sni_ticket.test.py new file mode 100644 index 00000000000..25f7473dc33 --- /dev/null +++ b/tests/gold_tests/tls/tls_sni_ticket.test.py @@ -0,0 +1,264 @@ +''' +Test sni.yaml session ticket overrides. +''' +# 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 re +from typing import Any + +Test.Summary = ''' +Test sni.yaml session ticket overrides +''' + +Test.SkipUnless(Condition.HasOpenSSLVersion('1.1.1')) +Test.Setup.Copy('file.ticket') + + +class TlsSniTicketTest: + _server_is_started = False + _ts_on_started = False + _ts_off_started = False + + def __init__(self) -> None: + """ + Initialize shared test state and configure the ATS processes. + """ + self.ticket_file = os.path.join(Test.RunDirectory, 'file.ticket') + self.setupOriginServer() + self.setupEnabledTS() + self.setupDisabledTS() + + def setupOriginServer(self) -> None: + """ + Configure the origin server with a simple response for all requests. + """ + request_header = { + 'headers': 'GET / HTTP/1.1\r\nHost: tickets.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': 'ticket test' + } + self.server = Test.MakeOriginServer('server') + self.server.addResponse('sessionlog.json', request_header, response_header) + + def setupTS( + self, + name: str, + sni_name: str, + global_ticket_enabled: int, + global_ticket_number: int, + sni_ticket_enabled: int, + sni_ticket_number: int | None = None) -> Any: + """ + Configure an ATS process for one SNI ticket override scenario. + + :param name: ATS process name. + :param sni_name: SNI hostname matched in sni.yaml. + :param global_ticket_enabled: Process-wide session ticket enable setting. + :param global_ticket_number: Process-wide TLSv1.3 ticket count. + :param sni_ticket_enabled: Per-SNI session ticket enable override. + :param sni_ticket_number: Per-SNI TLSv1.3 ticket count override. + :return: Configured ATS process. + """ + ts = Test.MakeATSProcess(name, enable_tls=True) + + ts.addSSLfile('ssl/server.pem') + ts.addSSLfile('ssl/server.key') + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self.server.Variables.Port}') + 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}', + 'proxy.config.exec_thread.autoconfig.scale': 1.0, + 'proxy.config.ssl.server.session_ticket.enable': global_ticket_enabled, + 'proxy.config.ssl.server.session_ticket.number': global_ticket_number, + 'proxy.config.ssl.server.ticket_key.filename': self.ticket_file, + }) + + sni_lines = [ + 'sni:', + f'- fqdn: {sni_name}', + f' ssl_ticket_enabled: {sni_ticket_enabled}', + ] + if sni_ticket_number is not None: + sni_lines.append(f' ssl_ticket_number: {sni_ticket_number}') + ts.Disk.sni_yaml.AddLines(sni_lines) + + return ts + + def setupEnabledTS(self) -> None: + """ + Create the ATS process whose SNI rule enables tickets. + """ + self.ts_on = self.setupTS('ts_on', 'tickets-on.com', 0, 0, 1, 3) + + def setupDisabledTS(self) -> None: + """ + Create the ATS process whose SNI rule disables tickets. + """ + self.ts_off = self.setupTS('ts_off', 'tickets-off.com', 1, 2, 0) + + def start_processes_if_needed( + self, tr: Any, start_server: bool = False, start_ts_on: bool = False, start_ts_off: bool = False) -> None: + """ + Register one-time StartBefore hooks for the processes needed by a test run. + + :param tr: The AuTest run definition being configured. + :param start_server: Whether the origin server should be started for this run. + :param start_ts_on: Whether the tickets-enabled ATS process should be started for this run. + :param start_ts_off: Whether the tickets-disabled ATS process should be started for this run. + """ + if start_server and not TlsSniTicketTest._server_is_started: + tr.Processes.Default.StartBefore(self.server) + TlsSniTicketTest._server_is_started = True + + if start_ts_on and not TlsSniTicketTest._ts_on_started: + tr.Processes.Default.StartBefore(self.ts_on) + TlsSniTicketTest._ts_on_started = True + + if start_ts_off and not TlsSniTicketTest._ts_off_started: + tr.Processes.Default.StartBefore(self.ts_off) + TlsSniTicketTest._ts_off_started = True + + @staticmethod + def check_regex_count(output_path: str, pattern: str, expected_count: int, description: str) -> tuple[bool, str, str]: + """ + Count regex matches in a process output file. + + :param output_path: Path to the output file to inspect. + :param pattern: Regex pattern to count. + :param expected_count: Expected number of matches. + :param description: Description reported by the tester. + :return: AuTest lambda result tuple. + """ + with open(output_path, 'r') as f: + content = f.read() + + matches = re.findall(pattern, content) + if len(matches) == expected_count: + return (True, description, f'Found {len(matches)} matches for {pattern}') + return (False, description, f'Expected {expected_count} matches for {pattern}, found {len(matches)}') + + @staticmethod + def session_reuse_command(port: int, servername: str) -> str: + """ + Build a TLSv1.2 resumption command for a specific SNI name. + + :param port: ATS TLS listening port. + :param servername: SNI hostname to send with the connection. + :return: Shell command for repeated TLSv1.2 session reuse attempts. + """ + return ( + f'session_path=`mktemp` && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_out "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2') + + def add_tls12_enabled_run(self) -> None: + """ + Register the TLSv1.2 resumption test for the enabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml enables TLSv1.2 ticket resumption') + tr.Command = TlsSniTicketTest.session_reuse_command(self.ts_on.Variables.ssl_port, 'tickets-on.com') + tr.ReturnCode = 0 + self.start_processes_if_needed(tr, start_server=True, start_ts_on=True) + tr.Processes.Default.Streams.All.Content = Testers.Lambda( + lambda info, tester: TlsSniTicketTest.check_regex_count( + tr.Processes.Default.Streams.All.AbsPath, r'Reused, TLSv1\.2', 5, + 'Check that tickets-on.com reuses TLSv1.2 sessions')) + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_on + + def add_tls13_enabled_run(self) -> None: + """ + Register the TLSv1.3 ticket count test for the enabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml sets TLSv1.3 ticket count') + tr.Command = ( + f'echo -e "GET / HTTP/1.1\\r\\nHost: tickets-on.com\\r\\nConnection: close\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{self.ts_on.Variables.ssl_port} -servername tickets-on.com -tls1_3 -msg -ign_eof') + tr.ReturnCode = 0 + self.start_processes_if_needed(tr, start_server=True, start_ts_on=True) + tr.Processes.Default.Streams.All.Content = Testers.Lambda( + lambda info, tester: TlsSniTicketTest.check_regex_count( + tr.Processes.Default.Streams.All.AbsPath, r'NewSessionTicket', 3, + 'Check that tickets-on.com receives three TLSv1.3 tickets')) + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_on + + def add_tls12_disabled_run(self) -> None: + """ + Register the TLSv1.2 non-resumption test for the disabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml disables TLSv1.2 ticket resumption') + tr.Command = TlsSniTicketTest.session_reuse_command(self.ts_off.Variables.ssl_port, 'tickets-off.com') + self.start_processes_if_needed(tr, start_server=True, start_ts_off=True) + tr.Processes.Default.Streams.All = Testers.ExcludesExpression('Reused', 'tickets-off.com should not reuse TLSv1.2 sessions') + tr.Processes.Default.Streams.All += Testers.ContainsExpression('TLSv1.2', 'tickets-off.com should still negotiate TLSv1.2') + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_off + + def add_tls13_disabled_run(self) -> None: + """ + Register the TLSv1.3 no-ticket test for the disabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml disables TLSv1.3 ticket issuance') + tr.Command = ( + f'echo -e "GET / HTTP/1.1\\r\\nHost: tickets-off.com\\r\\nConnection: close\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{self.ts_off.Variables.ssl_port} -servername tickets-off.com -tls1_3 -msg -ign_eof' + ) + self.start_processes_if_needed(tr, start_server=True, start_ts_off=True) + tr.Processes.Default.Streams.All.Content = Testers.Lambda( + lambda info, tester: TlsSniTicketTest.check_regex_count( + tr.Processes.Default.Streams.All.AbsPath, r'NewSessionTicket', 0, + 'Check that tickets-off.com receives no TLSv1.3 tickets')) + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_off + + def run(self) -> None: + """ + Register all AuTest runs for the SNI ticket override coverage. + """ + self.add_tls12_enabled_run() + self.add_tls13_enabled_run() + self.add_tls12_disabled_run() + self.add_tls13_disabled_run() + + +TlsSniTicketTest().run() From 3062ebeec83f02a3e5d7dce0403ee95ab7cf7b6b Mon Sep 17 00:00:00 2001 From: bneradt Date: Fri, 20 Mar 2026 16:16:01 -0500 Subject: [PATCH 2/2] docs: clarify BoringSSL does not support ticket number --- configs/sni.yaml.default | 2 ++ doc/admin-guide/files/records.yaml.en.rst | 5 +++++ doc/admin-guide/files/sni.yaml.en.rst | 4 ++++ src/iocore/net/YamlSNIConfig.cc | 6 ++++++ 4 files changed, 17 insertions(+) diff --git a/configs/sni.yaml.default b/configs/sni.yaml.default index ec7a94c39be..57a1ed24ed9 100644 --- a/configs/sni.yaml.default +++ b/configs/sni.yaml.default @@ -32,6 +32,8 @@ # This overrides proxy.config.ssl.server.session_ticket.enable. # ssl_ticket_number - sets the number of TLSv1.3 session tickets issued for matched inbound TLS connections; # parameters = INTEGER. This overrides proxy.config.ssl.server.session_ticket.number. +# BoringSSL does not support setting the ticket number on a per-SNI basis, +# so this configuration is ignored when ATS is linked against BoringSSL. # http2 - adds or removes HTTP/2 (H2) from the protocol list advertised by ATS; parameter required = None, parameters = on or off # tunnel_route - sets the e2e tunnel route # forward_route - destination as an FQDN and port, separated by a colon :. diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 43bf2ab7bba..b3877eff3ff 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -4190,6 +4190,11 @@ SSL Termination Increasing the number of tickets could be potentially beneficial for clients performing multiple requests over concurrent TLS connections as per RFC 8446 clients SHOULDN'T reuse TLS Tickets. + This setting is applied at the SSL context level. BoringSSL does not support setting the + ticket number on a per-SNI basis, so the :file:`sni.yaml` :code:`ssl_ticket_number` + configuration does not apply when ATS is linked against BoringSSL and this context-level + value remains in effect. + For more information see https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_set_num_tickets.html .. ts:cv:: CONFIG proxy.config.ssl.hsts_max_age INT -1 diff --git a/doc/admin-guide/files/sni.yaml.en.rst b/doc/admin-guide/files/sni.yaml.en.rst index 80489987cfe..404f7ea62b1 100644 --- a/doc/admin-guide/files/sni.yaml.en.rst +++ b/doc/admin-guide/files/sni.yaml.en.rst @@ -182,6 +182,10 @@ ssl_ticket_number Inbound Specifies an override to the :ts:cv:`proxy.config.ssl.server.session_ticket.number` :file:`records.yaml` configuration. This controls how many TLSv1.3 session tickets are issued for matching inbound TLS connections. + BoringSSL does not support setting the ticket number on a + per-SNI basis, so this configuration does not apply when ATS is + linked against BoringSSL. The configured ticket count from the + selected SSL context remains in effect. host_sni_policy Inbound One of the values :code:`DISABLED`, :code:`PERMISSIVE`, or :code:`ENFORCED`. diff --git a/src/iocore/net/YamlSNIConfig.cc b/src/iocore/net/YamlSNIConfig.cc index db3049655d5..ed1cd229162 100644 --- a/src/iocore/net/YamlSNIConfig.cc +++ b/src/iocore/net/YamlSNIConfig.cc @@ -168,6 +168,12 @@ YamlSNIConfig::Item::populate_sni_actions(action_vector_t &actions) actions.push_back(std::make_unique(ssl_ticket_enabled.value())); } if (ssl_ticket_number.has_value()) { +#if defined(OPENSSL_IS_BORINGSSL) + const char *servername = fqdn.empty() ? "*" : fqdn.c_str(); + Warning( + "sni.yaml: BoringSSL does not support setting the session ticket number, so ssl_ticket_number does not apply for fqdn '%s'", + servername); +#endif actions.push_back(std::make_unique(ssl_ticket_number.value())); } if (http2_buffer_water_mark.has_value()) {