This is an automated email from the ASF dual-hosted git repository.
eze pushed a commit to branch 9.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/9.2.x by this push:
new 0d4d981552 Support CLIENT_HELLO split across multiple packets (#12319)
0d4d981552 is described below
commit 0d4d98155281a91196734158b1f94d86b874a9d4
Author: Brian Neradt <[email protected]>
AuthorDate: Mon Jun 30 17:12:31 2025 -0500
Support CLIENT_HELLO split across multiple packets (#12319)
Our TLS CLIENT_HELLO processing logic assumed all CLIENT_HELLO bytes
came in a single TCP packet. However, with more recent cryptographic
ciphers, the CLIENT_HELLO is often greater than the standard 1,500 byte
MTU, so the CLIENT_HELLO is being delivered in multiple packets. This
updates our logic to properly buffer and parse data across multiple
socket reads.
Fixes: #11758
---
iocore/net/P_SSLNetVConnection.h | 71 +++++++-
iocore/net/SSLNetVConnection.cc | 177 +++++++++++---------
tests/gold_tests/tls/receive_split_client_hello.py | 129 +++++++++++++++
tests/gold_tests/tls/split_client_hello.py | 181 +++++++++++++++++++++
tests/gold_tests/tls/tls_tunnel.test.py | 64 +++++++-
5 files changed, 533 insertions(+), 89 deletions(-)
diff --git a/iocore/net/P_SSLNetVConnection.h b/iocore/net/P_SSLNetVConnection.h
index 67ae7ea716..288303e4f4 100644
--- a/iocore/net/P_SSLNetVConnection.h
+++ b/iocore/net/P_SSLNetVConnection.h
@@ -176,6 +176,26 @@ public:
int64_t read_raw_data();
+ /** Initialize handshake buffers in which we store TLS handshake data.
+ *
+ * Typically, we would configure the SSL object to use the socket directly,
+ * and call SSL_read on the socket. In this way, the OpenSSL machine would
+ * read and parse the stream for us, handshake and all. We cannot, however,
+ * blindly let OpenSSL read off the socket since we may need to replay the
+ * CLIENT_HELLO raw bytes to the origin if we wind up blind tunneling the
+ * connection. Therefore, for the initial CLIENT_HELLO, we:
+ *
+ * 1. Manually read bytes off the socket via read_raw_data().
+ * 2. Store the bytes in @a handShakeBuffer.
+ * 3. Configure our SSL object to read from a memory buffer populated from @a
+ * handshakeReader.
+ *
+ * Once the CLIENT_HELLO is parsed, we either configure the SSL object to
read
+ * from the socket as normal, or we replay the bytes to the origin via @a
+ * handshakeHolder if we decide to blind tunnel the connection. In the latter
+ * tunnel case, any subsequent bytes are blindly tunneled between the origin
+ * and the client.
+ */
void
initialize_handshake_buffers()
{
@@ -197,10 +217,14 @@ public:
if (this->handShakeBuffer) {
free_MIOBuffer(this->handShakeBuffer);
}
- this->handShakeReader = nullptr;
- this->handShakeHolder = nullptr;
- this->handShakeBuffer = nullptr;
- this->handShakeBioStored = 0;
+ if (this->coalescedHandShakeBioBuffer != nullptr) {
+ ats_free(this->coalescedHandShakeBioBuffer);
+ }
+ this->handShakeReader = nullptr;
+ this->handShakeHolder = nullptr;
+ this->handShakeBuffer = nullptr;
+ this->handShakeBioStored = 0;
+ this->coalescedHandShakeBioBuffer = nullptr;
}
// Returns true if all the hooks reenabled
@@ -457,13 +481,44 @@ private:
NetProcessor *_getNetProcessor() override;
void *_prepareForMigration() override;
+ /** Return the unconsumed bytes in @a handShakeReader in a contiguous memory
buffer.
+ *
+ * If @a handShakeReader is a single IOBufferBlock, this returns the pointer
+ * to the data in that block. Otherwise, memory is allocated in @a
+ * handshakeReaderCoalesced and the bytes are copied into it. Regardless, any
+ * previously allocated memory in @a coalescedHandShakeBioBuffer is freed
when
+ * this function is called.
+ *
+ * @param[in] total_chain_size The total size of the bytes in @a
+ * handShakeReader across all IOBufferBlocks.
+ *
+ * @return A pointer to all unconsumed bytes in @a handShakeReader in a
single
+ * contiguous memory buffer.
+ */
+ char *_getCoalescedHandShakeBuffer(int64_t total_chain_size);
+
enum SSLHandshakeStatus sslHandshakeStatus = SSL_HANDSHAKE_ONGOING;
bool sslClientRenegotiationAbort = false;
bool first_ssl_connect = true;
- MIOBuffer *handShakeBuffer = nullptr;
- IOBufferReader *handShakeHolder = nullptr;
- IOBufferReader *handShakeReader = nullptr;
- int handShakeBioStored = 0;
+
+ /** The buffer storing the initial CLIENT_HELLO bytes. */
+ MIOBuffer *handShakeBuffer = nullptr;
+
+ /** Used to incrementally shuffle bytes read off the socket to the SSL
object. */
+ IOBufferReader *handShakeHolder = nullptr;
+
+ /** If blind tunneling, this supplies the initial raw bytes of the
CLIENT_HELLO. */
+ IOBufferReader *handShakeReader = nullptr;
+
+ /** A buffer for the Coalesced @a handShakeReader bytes if @a handShakeReader
+ * spans multiple IOBufferBlocks. */
+ char *coalescedHandShakeBioBuffer = nullptr;
+
+ /** The number of bytes last send to the SSL's BIO. */
+ int handShakeBioStored = 0;
+
+ /** Whether we have already checked for Proxy Protocol in the initial
packet. */
+ bool haveCheckedProxyProtocol = false;
bool transparentPassThrough = false;
diff --git a/iocore/net/SSLNetVConnection.cc b/iocore/net/SSLNetVConnection.cc
index 9124780630..efd41c0a17 100644
--- a/iocore/net/SSLNetVConnection.cc
+++ b/iocore/net/SSLNetVConnection.cc
@@ -414,47 +414,53 @@ SSLNetVConnection::read_raw_data()
}
NET_SUM_DYN_STAT(net_read_bytes_stat, r);
- IpMap *pp_ipmap;
- pp_ipmap = SSLConfigParams::proxy_protocol_ipmap;
-
- if (this->get_is_proxy_protocol() && this->get_proxy_protocol_version() ==
ProxyProtocolVersion::UNDEFINED) {
- Debug("proxyprotocol", "proxy protocol is enabled on this port");
- if (pp_ipmap->count() > 0) {
- Debug("proxyprotocol", "proxy protocol has a configured allowlist of
trusted IPs - checking");
-
- // At this point, using get_remote_addr() will return the ip of the
- // proxy source IP, not the Proxy Protocol client ip. Since we are
- // checking the ip of the actual source of this connection, this is
- // what we want now.
- void *payload = nullptr;
- if (!pp_ipmap->contains(get_remote_addr(), &payload)) {
- Debug("proxyprotocol", "proxy protocol src IP is NOT in the configured
allowlist of trusted IPs - "
- "closing connection");
- r = -ENOTCONN; // Need a quick close/exit here to refuse the
connection!!!!!!!!!
- goto proxy_protocol_bypass;
+ if (!this->haveCheckedProxyProtocol) {
+ // The PROXY Protocol, by spec, is designed to require only the first TCP
packet of bytes
+ // because it is under typical MTU. So we only need to perform the
following inspection on the
+ // first packet.
+ this->haveCheckedProxyProtocol = true;
+ IpMap *pp_ipmap;
+ pp_ipmap = SSLConfigParams::proxy_protocol_ipmap;
+
+ if (this->get_is_proxy_protocol() && this->get_proxy_protocol_version() ==
ProxyProtocolVersion::UNDEFINED) {
+ Debug("proxyprotocol", "proxy protocol is enabled on this port");
+ if (pp_ipmap->count() > 0) {
+ Debug("proxyprotocol", "proxy protocol has a configured allowlist of
trusted IPs - checking");
+
+ // At this point, using get_remote_addr() will return the ip of the
+ // proxy source IP, not the Proxy Protocol client ip. Since we are
+ // checking the ip of the actual source of this connection, this is
+ // what we want now.
+ void *payload = nullptr;
+ if (!pp_ipmap->contains(get_remote_addr(), &payload)) {
+ Debug("proxyprotocol", "proxy protocol src IP is NOT in the
configured allowlist of trusted IPs - "
+ "closing connection");
+ r = -ENOTCONN; // Need a quick close/exit here to refuse the
connection!!!!!!!!!
+ goto proxy_protocol_bypass;
+ } else {
+ char new_host[INET6_ADDRSTRLEN];
+ Debug("proxyprotocol", "Source IP [%s] is in the trusted allowlist
for proxy protocol",
+ ats_ip_ntop(this->get_remote_addr(), new_host,
sizeof(new_host)));
+ }
} else {
- char new_host[INET6_ADDRSTRLEN];
- Debug("proxyprotocol", "Source IP [%s] is in the trusted allowlist for
proxy protocol",
- ats_ip_ntop(this->get_remote_addr(), new_host,
sizeof(new_host)));
+ Debug("proxyprotocol", "proxy protocol DOES NOT have a configured
allowlist of trusted IPs but "
+ "proxy protocol is enabled on this port -
processing all connections");
}
- } else {
- Debug("proxyprotocol", "proxy protocol DOES NOT have a configured
allowlist of trusted IPs but "
- "proxy protocol is enabled on this port -
processing all connections");
- }
- if (this->has_proxy_protocol(buffer, &r)) {
- Debug("proxyprotocol", "ssl has proxy protocol header");
- set_remote_addr(get_proxy_protocol_src_addr());
- if (is_debug_tag_set("proxyprotocol")) {
- IpEndpoint dst;
- dst.sa = *(this->get_proxy_protocol_dst_addr());
- ip_port_text_buffer ipb1;
- ats_ip_nptop(&dst, ipb1, sizeof(ipb1));
- Debug("proxyprotocol", "ssl_has_proxy_v1, dest IP received [%s]",
ipb1);
+ if (this->has_proxy_protocol(buffer, &r)) {
+ Debug("proxyprotocol", "ssl has proxy protocol header");
+ set_remote_addr(get_proxy_protocol_src_addr());
+ if (is_debug_tag_set("proxyprotocol")) {
+ IpEndpoint dst;
+ dst.sa = *(this->get_proxy_protocol_dst_addr());
+ ip_port_text_buffer ipb1;
+ ats_ip_nptop(&dst, ipb1, sizeof(ipb1));
+ Debug("proxyprotocol", "ssl_has_proxy_v1, dest IP received [%s]",
ipb1);
+ }
+ } else {
+ Debug("proxyprotocol", "proxy protocol was enabled, but required
header was not present in the "
+ "transaction - closing connection");
}
- } else {
- Debug("proxyprotocol", "proxy protocol was enabled, but required header
was not present in the "
- "transaction - closing connection");
}
} // end of Proxy Protocol processing
@@ -463,13 +469,13 @@ proxy_protocol_bypass:
if (r > 0) {
this->handShakeBuffer->fill(r);
- char *start = this->handShakeReader->start();
- char *end = this->handShakeReader->end();
- this->handShakeBioStored = end - start;
+ auto const total_chain_size = this->handShakeReader->read_avail();
+ this->handShakeBioStored = total_chain_size;
+ char *buffer_for_bio =
this->_getCoalescedHandShakeBuffer(total_chain_size);
// Sets up the buffer as a read only bio target
// Must be reset on each read
- BIO *rbio = BIO_new_mem_buf(start, this->handShakeBioStored);
+ BIO *rbio = BIO_new_mem_buf(buffer_for_bio, this->handShakeBioStored);
BIO_set_mem_eof_return(rbio, -1);
SSL_set0_rbio(this->ssl, rbio);
} else {
@@ -497,23 +503,25 @@ SSLNetVConnection::update_rbio(bool move_to_socket)
{
bool retval = false;
if (BIO_eof(SSL_get_rbio(this->ssl)) && this->handShakeReader != nullptr) {
+ Debug("ssl", "Consuming handShakeBioStored=%d bytes from the handshake
reader", this->handShakeBioStored);
this->handShakeReader->consume(this->handShakeBioStored);
this->handShakeBioStored = 0;
// Load up the next block if present
if (this->handShakeReader->is_read_avail_more_than(0)) {
- // Setup the next iobuffer block to drain
- char *start = this->handShakeReader->start();
- char *end = this->handShakeReader->end();
- this->handShakeBioStored = end - start;
+ auto const total_chain_size = this->handShakeReader->read_avail();
+ this->handShakeBioStored = total_chain_size;
+ char *buffer_for_bio =
this->_getCoalescedHandShakeBuffer(total_chain_size);
+ Debug("ssl", "Adding %d bytes to the ssl rbio",
this->handShakeBioStored);
// Sets up the buffer as a read only bio target
// Must be reset on each read
- BIO *rbio = BIO_new_mem_buf(start, this->handShakeBioStored);
+ BIO *rbio = BIO_new_mem_buf(buffer_for_bio, this->handShakeBioStored);
BIO_set_mem_eof_return(rbio, -1);
SSL_set0_rbio(this->ssl, rbio);
retval = true;
// Handshake buffer is empty but we have read something, move to the
socket rbio
} else if (move_to_socket &&
this->handShakeHolder->is_read_avail_more_than(0)) {
+ Debug("ssl", "No other bytes in the handshake reader, moving to socket
rbio");
BIO *rbio = BIO_new_fd(this->get_socket(), BIO_NOCLOSE);
BIO_set_mem_eof_return(rbio, -1);
SSL_set0_rbio(this->ssl, rbio);
@@ -602,6 +610,7 @@ SSLNetVConnection::net_read_io(NetHandler *nh, EThread
*lthread)
int64_t r = buf.writer()->write(this->handShakeHolder);
s->vio.nbytes += r;
s->vio.ndone += r;
+ Debug("ssl", "Copied %" PRId64 " TLS handshake bytes to read.vio",
r);
// Clean up the handshake buffers
this->free_handshake_buffers();
@@ -633,7 +642,12 @@ SSLNetVConnection::net_read_io(NetHandler *nh, EThread
*lthread)
}
// move over to the socket if we haven't already
if (this->handShakeBuffer != nullptr) {
- read.triggered = update_rbio(true);
+ bool const in_client_hello = sslHandshakeHookState ==
HANDSHAKE_HOOKS_CLIENT_HELLO;
+ // Only transfer buffers to the socket once the CLIENT_HELLO is
+ // finished. We need to keep our buffers updated until then in case we
+ // enter tunnel mode.
+ Debug("ssl", "Updating our buffers, in CLIENT_HELLO: %s",
in_client_hello ? "true" : "false");
+ read.triggered = update_rbio(!in_client_hello);
} else {
read.triggered = 0;
}
@@ -1238,39 +1252,38 @@ SSLNetVConnection::sslServerHandShakeEvent(int &err)
Debug("ssl", "Go on with the handshake state=%d", sslHandshakeHookState);
// All the pre-accept hooks have completed, proceed with the actual accept.
- if (this->handShakeReader) {
+ bool const in_client_hello = sslHandshakeHookState ==
HANDSHAKE_HOOKS_CLIENT_HELLO;
+ // We only feed CLIENT_HELLO bytes into our temporary buffers. If we are past
+ // the CLIENT_HELLO, then no need to buffer.
+ if (in_client_hello && this->handShakeReader) {
if (BIO_eof(SSL_get_rbio(this->ssl))) { // No more data in the buffer
- // Is this the first read?
- if (!this->handShakeReader->is_read_avail_more_than(0) &&
!this->handShakeHolder->is_read_avail_more_than(0)) {
+ // Is this the first read?
#if TS_USE_TLS_ASYNC
- if (SSLConfigParams::async_handshake_enabled) {
- SSL_set_mode(ssl, SSL_MODE_ASYNC);
- }
+ if (SSLConfigParams::async_handshake_enabled) {
+ SSL_set_mode(ssl, SSL_MODE_ASYNC);
+ }
#endif
- Debug("ssl", "%p first read\n", this);
- // Read from socket to fill in the BIO buffer with the
- // raw handshake data before calling the ssl accept calls.
- int retval = this->read_raw_data();
- if (retval < 0) {
- if (retval == -EAGAIN) {
- // No data at the moment, hang tight
- SSLVCDebug(this, "SSL handshake: EAGAIN");
- return SSL_HANDSHAKE_WANT_READ;
- } else {
- // An error, make us go away
- SSLVCDebug(this, "SSL handshake error: read_retval=%d", retval);
- return EVENT_ERROR;
- }
- } else if (retval == 0) {
- // EOF, go away, we stopped in the handshake
- SSLVCDebug(this, "SSL handshake error: EOF");
+ Debug("ssl", "%p reading off the socket into our buffers", this);
+ // Read from socket to fill in the BIO buffer with the
+ // raw handshake data before calling the ssl accept calls.
+ int retval = this->read_raw_data();
+ if (retval < 0) {
+ if (retval == -EAGAIN) {
+ // No data at the moment, hang tight
+ SSLVCDebug(this, "SSL handshake: EAGAIN");
+ return SSL_HANDSHAKE_WANT_READ;
+ } else {
+ // An error, make us go away
+ SSLVCDebug(this, "SSL handshake error: read_retval=%d", retval);
return EVENT_ERROR;
}
- } else {
- update_rbio(false);
+ } else if (retval == 0) {
+ // EOF, go away, we stopped in the handshake
+ SSLVCDebug(this, "SSL handshake error: EOF");
+ return EVENT_ERROR;
}
- } // Still data in the BIO
+ } // Still data in the BIO. Let OpenSSL consume that first before doing
anything else.
}
ssl_error_t ssl_error = this->_ssl_accept();
@@ -1991,6 +2004,24 @@ SSLNetVConnection::_getNetProcessor()
return &sslNetProcessor;
}
+char *
+SSLNetVConnection::_getCoalescedHandShakeBuffer(int64_t total_chain_size)
+{
+ if (this->coalescedHandShakeBioBuffer != nullptr) {
+ ats_free(this->coalescedHandShakeBioBuffer);
+ this->coalescedHandShakeBioBuffer = nullptr;
+ }
+ char *start = this->handShakeReader->start();
+ char *end = this->handShakeReader->end();
+ char *coalescedBuffer = start;
+ if ((end - start) < total_chain_size) {
+ this->coalescedHandShakeBioBuffer = static_cast<char
*>(ats_malloc(total_chain_size));
+ this->handShakeReader->memcpy(this->coalescedHandShakeBioBuffer,
total_chain_size);
+ coalescedBuffer = this->coalescedHandShakeBioBuffer;
+ }
+ return coalescedBuffer;
+}
+
ssl_curve_id
SSLNetVConnection::_get_tls_curve() const
{
diff --git a/tests/gold_tests/tls/receive_split_client_hello.py
b/tests/gold_tests/tls/receive_split_client_hello.py
new file mode 100644
index 0000000000..559961ca72
--- /dev/null
+++ b/tests/gold_tests/tls/receive_split_client_hello.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""Receive a split CLIENT_HELLO over TCP, send a dummy response, then echo all
+further data."""
+
+# 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 sys
+
+# See split_client_hello.py for the original CLIENT_HELLO packet.
+EXPECTED_CLIENT_HELLO_LENGTH = 1993
+
+
+def parse_args() -> argparse.Namespace:
+ '''Parse command line arguments.
+ :returns: Parsed command line arguments.
+ '''
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("address", help="Address to listen on")
+ parser.add_argument("port", type=int, help="Port to listen on")
+ return parser.parse_args()
+
+
+def get_listening_socket(address: str, port: int) -> socket.socket:
+ '''Create a socket that listens for incoming connections.
+ :param address: The address to listen on.
+ :param port: The port to listen on.
+ :returns: A listening socket.
+ '''
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.setblocking(True)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind((address, port))
+ sock.listen(1)
+ return sock
+
+
+def accept_connection(listener: socket.socket) -> socket.socket:
+ '''Accept a connection on the given listener socket.
+
+ This will block until a connection comes in and is accepted.
+
+ :param listener: The socket to accept a connection on.
+ :returns: The accepted socket.
+ '''
+ return listener.accept()[0]
+
+
+def handle_client_connection(listener: socket.socket) -> int:
+ '''Handle a client connection by reading data and echoing it back.
+
+ :param listener: The socket listening for client connections.
+ :returns: 0 on success, non-zero on error.
+ '''
+ while True:
+ with accept_connection(listener) as conn:
+ conn.setblocking(True)
+ print(f'Accepted connection from {conn.getpeername()}')
+ try:
+ data = conn.recv(65536)
+ except socket.error as e:
+ print(f"Socket error on recv: {e}")
+ break
+ if not data:
+ # This is probably the PortOpenv4 test. Try again.
+ print("No bytes read on this connection. Trying again.")
+ continue
+ print(f"Received CLIENT_HELLO packet of {len(data)} bytes, sending
dummy response.")
+ if len(data) != EXPECTED_CLIENT_HELLO_LENGTH:
+ print(f'Incorrect CLIENT_HELLO length: {len(data)} bytes,
expected {EXPECTED_CLIENT_HELLO_LENGTH} bytes.')
+ return 1
+ try:
+ conn.sendall(b"dummy SERVER_HELLO")
+ except socket.error as e:
+ print(f"Failed to send dummy SERVER_HELLO response: {e}")
+ break
+ # Echo loop.
+ while True:
+ try:
+ chunk = conn.recv(65536)
+ except socket.error:
+ break
+ if not chunk:
+ break
+ print(f'Received chunk of {len(chunk)} bytes, echoing back:')
+ print(chunk)
+ conn.sendall(chunk)
+ # Done with this client connection.
+ print(f"Client {conn.getpeername()} disconnected. Successfully
handled connection.")
+ return 0
+ return 1
+
+
+def main() -> int:
+ args = parse_args()
+ with get_listening_socket(args.address, args.port) as listener:
+ print(f"Listening on {args.address}:{args.port}")
+ while True:
+ try:
+ ret = handle_client_connection(listener)
+ if ret != 0:
+ print(f"Error handling client connection")
+ return ret
+ except KeyboardInterrupt:
+ print("Server shutting down.")
+ break
+ except Exception as e:
+ print(f"An error occurred: {e}")
+ continue
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/gold_tests/tls/split_client_hello.py
b/tests/gold_tests/tls/split_client_hello.py
new file mode 100644
index 0000000000..6ebf119d13
--- /dev/null
+++ b/tests/gold_tests/tls/split_client_hello.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+'''Send a pre-recorded TLS CLIENT_HELLO as a configurable number
+of packets to a 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 argparse
+import base64
+import socket
+import sys
+import time
+
+# This is a ClientHello packet captured from a real client using Wireshark.
+# The packet includes key material necessary for the Kyber cipher handshake.
+#
+# NOTE: if this changes, update EXPECTED_CLIENT_HELLO_LENGTH in the
+# corresponding receive_split_client_hello.py script.
+client_hello_b64 = \
+"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"
+
+client_hello = base64.b64decode(client_hello_b64, validate=True)
+
+
+def send_clienthello(dest_host: str, dest_port: int, split_size: int,
num_data_packets: int) -> int:
+ '''Send a pre-recorded TLS CLIENT_HELLO to a server.
+ :param dest_host: The destination host to which to send the CLIENT_HELLO.
+ :param dest_port: The destination port to which to send the CLIENT_HELLO.
+ :param split_size: The size of each split packet. 0 means no split,
+ send the whole packet at once.
+ :param num_data_packets: The number of data packets to send.
+ :return: 0 on success, non-zero on failure.
+ '''
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.setblocking(True)
+ timeout = 3
+ s.settimeout(timeout)
+ print(f'Connecting to: {dest_host}:{dest_port}')
+ try:
+ s.connect((dest_host, dest_port))
+ except socket.error as e:
+ print(f"Failed to connect to {dest_host}:{dest_port} - {e}")
+ return 1
+ print('Connection successful.')
+ split_size = split_size if split_size > 0 else len(client_hello)
+ client_hello_packets = [client_hello[i:i+split_size] \
+ for i in range(0, len(client_hello),
split_size)]
+ print(
+ f'Sending ClientHello of {len(client_hello)} bytes in {split_size}
'
+ f'byte packets, {len(client_hello_packets)} total packets.')
+ for packet in client_hello_packets:
+ print(f'Sending packet of size {len(packet)} bytes.')
+ s.send(packet)
+ time.sleep(0.05)
+ print('CLIENT_HELLO sent, waiting for response.')
+ try:
+ buff = s.recv(10240)
+ except socket.timeout:
+ print(f"Failed: no response received within {timeout} seconds.")
+ return 1
+ except socket.error as e:
+ print(f"Failed to receive response - {e}")
+ return 1
+ except Exception as e:
+ print(f"An unexpected error occurred while receiving response -
{e}")
+ return 1
+ print("Response received:")
+ print(buff.decode('utf-8', errors='ignore'))
+
+ print(f'Sending {num_data_packets} dummy data packets.')
+ for i in range(num_data_packets):
+ time.sleep(0.05)
+ print(f'Sending dummy data packet {i} of size {len(buff)} bytes.')
+ send_buf = f'data: {i}\n'.encode('utf-8')
+ try:
+ s.send(send_buf)
+ except socket.error as e:
+ print(f"Failed to send dummy data packet {i} - {e}")
+ return 1
+ # Wait for a response after each data packet
+ try:
+ response_buff = s.recv(10240)
+ except socket.timeout:
+ print(f"Failed: no response received after data packet {i}.")
+ return 1
+ except socket.error as e:
+ print(f"Failed to receive response after data packet {i} -
{e}")
+ return 1
+ except Exception as e:
+ print(f"An unexpected error occurred while receiving response
after data packet {i} - {e}")
+ return 1
+ print(f"Response after data packet {i} received:")
+ print(response_buff.decode('utf-8', errors='ignore'))
+ # Verify that the response was an echo of the request.
+ if response_buff != send_buf:
+ print(f"Response after data packet {i} did not match sent
data: {response_buff}")
+ return 1
+ else:
+ print(f"Response after data packet {i} matched sent data.")
+ print("All packets sent successfully.")
+ return 0
+
+
+def parse_args() -> argparse.Namespace:
+ '''Parse command line arguments.
+ :return: The parsed arguments.
+ '''
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("dest_host", help="Destination host to which to send
the CLIENT_HELLO.")
+ parser.add_argument('dest_port', type=int, help="Destination port to which
to send the CLIENT_HELLO.")
+ parser.add_argument(
+ '--split_size',
+ '-s',
+ type=int,
+ default=1100,
+ help="Size of each split packet. Default is 1100 bytes. "
+ "0 means no split, send the whole packet at once.")
+ parser.add_argument('--num_data_packets', '-d', type=int, default=2,
help="Number of data packets to send. Default is 2.")
+ return parser.parse_args()
+
+
+def main() -> int:
+ '''Send the CLIENT_HELLO split into two packets.
+ :return: 0 on success, non-zero on failure.
+ '''
+ args = parse_args()
+ status = send_clienthello(args.dest_host, args.dest_port, args.split_size,
args.num_data_packets)
+ if status == 0:
+ print("CLIENT_HELLO sent successfully.")
+ else:
+ print("CLIENT_HELLO failed.")
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/gold_tests/tls/tls_tunnel.test.py
b/tests/gold_tests/tls/tls_tunnel.test.py
index 986c3c91b3..440e76bc45 100644
--- a/tests/gold_tests/tls/tls_tunnel.test.py
+++ b/tests/gold_tests/tls/tls_tunnel.test.py
@@ -16,6 +16,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from ports import get_port
+import sys
+
Test.Summary = '''
Test tunneling based on SNI
'''
@@ -34,6 +37,12 @@ response_bar_header = {"headers": "HTTP/1.1 200
OK\r\nConnection: close\r\n\r\n"
server_foo.addResponse("sessionlog.json", request_foo_header,
response_foo_header)
server_bar.addResponse("sessionlog.json", request_bar_header,
response_bar_header)
+# Server to interact with a split CLIENT_HELLO in a blind tunnel.
+split_client_hello_server =
Test.Processes.Process('receive_split_client_hello_server')
+server_port = get_port(split_client_hello_server, 'tcp_port')
+server_command = f'{sys.executable} receive_split_client_hello.py 127.0.0.1
{server_port}'
+split_client_hello_server.Command = server_command
+
# add ssl materials like key, certificates for the server
ts.addSSLfile("ssl/signed-foo.pem")
ts.addSSLfile("ssl/signed-foo.key")
@@ -59,12 +68,14 @@ ts.Disk.records_config.update(
'proxy.config.ssl.server.cert.path': '{0}'.format(ts.Variables.SSLDir),
'proxy.config.ssl.server.private_key.path':
'{0}'.format(ts.Variables.SSLDir),
'proxy.config.http.connect_ports':
- '{0} {1} {2}'.format(ts.Variables.ssl_port,
server_foo.Variables.SSL_Port, server_bar.Variables.SSL_Port),
+ '{0} {1} {2} {3}'.format(ts.Variables.ssl_port,
server_foo.Variables.SSL_Port, server_bar.Variables.SSL_Port,
split_client_hello_server.Variables.tcp_port),
'proxy.config.ssl.client.CA.cert.path':
'{0}'.format(ts.Variables.SSLDir),
'proxy.config.ssl.client.CA.cert.filename': 'signer.pem',
'proxy.config.exec_thread.autoconfig.scale': 1.0,
'proxy.config.url_remap.pristine_host_hdr': 1,
- 'proxy.config.dns.nameservers':
'127.0.0.1:{0}'.format(dns.Variables.Port),
+ 'proxy.config.diags.debug.enabled': 1,
+ 'proxy.config.diags.debug.tags': 'http|ssl|proxyprotocol',
+ 'proxy.config.dns.nameservers': f'127.0.0.1:{dns.Variables.Port}',
'proxy.config.dns.resolv_conf': 'NULL'
})
@@ -75,18 +86,21 @@ ts.Disk.sni_yaml.AddLines(
[
'sni:',
'- fqdn: foo.com',
- " tunnel_route: localhost:{0}".format(server_foo.Variables.SSL_Port),
+ f" tunnel_route: localhost:{server_foo.Variables.SSL_Port}",
+ '- fqdn: slashdot.org',
+ f" tunnel_route:
localhost:{split_client_hello_server.Variables.tcp_port}",
"- fqdn: bob.*.com",
- " tunnel_route: localhost:{0}".format(server_foo.Variables.SSL_Port),
+ f" tunnel_route: localhost:{server_foo.Variables.SSL_Port}",
"- fqdn: '*.match.com'",
- " tunnel_route:
$1.testmatch:{0}".format(server_foo.Variables.SSL_Port),
+ f" tunnel_route: $1.testmatch:{server_foo.Variables.SSL_Port}",
"- fqdn: '*.ok.*.com'",
- " tunnel_route:
$2.example.$1:{0}".format(server_foo.Variables.SSL_Port),
+ f" tunnel_route: $2.example.$1:{server_foo.Variables.SSL_Port}",
"- fqdn: ''", # No SNI sent
- " tunnel_route: localhost:{0}".format(server_bar.Variables.SSL_Port)
+ f" tunnel_route: localhost:{server_bar.Variables.SSL_Port}"
])
tr = Test.AddTestRun("foo.com Tunnel-test")
+tr.TimeOut = 5
tr.Processes.Default.Command = "curl -v --resolve 'foo.com:{0}:127.0.0.1' -k
https://foo.com:{0}".format(ts.Variables.ssl_port)
tr.ReturnCode = 0
tr.Processes.Default.StartBefore(server_foo)
@@ -103,6 +117,7 @@ tr.Processes.Default.Streams.All +=
Testers.ExcludesExpression("ATS", "Do not te
tr.Processes.Default.Streams.All += Testers.ContainsExpression("foo ok",
"Should get a response from bar")
tr = Test.AddTestRun("bob.bar.com Tunnel-test")
+tr.TimeOut = 5
tr.Processes.Default.Command = "curl -v --resolve 'bob.bar.com:{0}:127.0.0.1'
-k https://bob.bar.com:{0}".format(
ts.Variables.ssl_port)
tr.ReturnCode = 0
@@ -116,6 +131,7 @@ tr.Processes.Default.Streams.All +=
Testers.ExcludesExpression("ATS", "Do not te
tr.Processes.Default.Streams.All += Testers.ContainsExpression("foo ok",
"Should get a response from bar")
tr = Test.AddTestRun("bar.com no Tunnel-test")
+tr.TimeOut = 5
tr.Processes.Default.Command = "curl -v --resolve 'bar.com:{0}:127.0.0.1' -k
https://bar.com:{0}".format(ts.Variables.ssl_port)
tr.ReturnCode = 0
tr.StillRunningAfter = ts
@@ -124,6 +140,7 @@ tr.Processes.Default.Streams.All +=
Testers.ContainsExpression("Not Found on Acc
tr.Processes.Default.Streams.All += Testers.ContainsExpression("ATS",
"Terminate on Traffic Server")
tr = Test.AddTestRun("no SNI Tunnel-test")
+tr.TimeOut = 5
tr.Processes.Default.Command = "curl -v -k
https://127.0.0.1:{0}".format(ts.Variables.ssl_port)
tr.ReturnCode = 0
tr.StillRunningAfter = ts
@@ -135,6 +152,7 @@ tr.Processes.Default.Streams.All +=
Testers.ExcludesExpression("ATS", "Do not te
tr.Processes.Default.Streams.All += Testers.ContainsExpression("bar ok",
"Should get a response from bar")
tr = Test.AddTestRun("one.match.com Tunnel-test")
+tr.TimeOut = 5
tr.Processes.Default.Command = "curl -vvv --resolve
'one.match.com:{0}:127.0.0.1' -k https://one.match.com:{0}".format(
ts.Variables.ssl_port)
tr.ReturnCode = 0
@@ -148,6 +166,7 @@ tr.Processes.Default.Streams.All +=
Testers.ExcludesExpression("ATS", "Do not te
tr.Processes.Default.Streams.All += Testers.ContainsExpression("foo ok",
"Should get a response from tm")
tr = Test.AddTestRun("one.ok.two.com Tunnel-test")
+tr.TimeOut = 5
tr.Processes.Default.Command = "curl -vvv --resolve
'one.ok.two.com:{0}:127.0.0.1' -k https:/one.ok.two.com:{0}".format(
ts.Variables.ssl_port)
tr.ReturnCode = 0
@@ -178,6 +197,35 @@ tr.Processes.Default.Env = ts.Env
tr.Processes.Default.Command = 'echo Updated configs'
tr.Processes.Default.ReturnCode = 0
+# Test a large CLIENT_HELLO.
+tr = Test.AddTestRun("Single large CLIENT_HELLO blind tunnel test")
+tr.TimeOut = 5
+tr.Setup.Copy('split_client_hello.py')
+tr.Setup.Copy('receive_split_client_hello.py')
+p = tr.Processes.Default
+p.StartBefore(split_client_hello_server, ready=When.PortOpen(server_port))
+p.Command = f'{sys.executable} split_client_hello.py 127.0.0.1
{ts.Variables.ssl_port} -s 0'
+p.ReturnCode = 0
+tr.StillRunningAfter = ts
+tr.StillRunningAfter = dns
+p.Streams.All += Testers.ContainsExpression('dummy SERVER_HELLO', 'Verify a
dummy SERVER_HELLO response is received')
+p.Streams.All += Testers.ContainsExpression('data: 0', 'Verify that the first
data packet was received.')
+p.Streams.All += Testers.ContainsExpression('data: 1', 'Verify that the second
data packet was received.')
+
+# Test a CLIENT_HELLO split over two TCP packets.
+tr = Test.AddTestRun("Split large CLIENT_HELLO blind tunnel test")
+tr.TimeOut = 5
+tr.Setup.Copy('split_client_hello.py')
+tr.Setup.Copy('receive_split_client_hello.py')
+p = tr.Processes.Default
+p.Command = f'{sys.executable} split_client_hello.py 127.0.0.1
{ts.Variables.ssl_port}'
+p.ReturnCode = 0
+tr.StillRunningAfter = ts
+tr.StillRunningAfter = dns
+p.Streams.All += Testers.ContainsExpression('dummy SERVER_HELLO', 'Verify a
dummy SERVER_HELLO response is received')
+p.Streams.All += Testers.ContainsExpression('data: 0', 'Verify that the first
data packet was received.')
+p.Streams.All += Testers.ContainsExpression('data: 1', 'Verify that the second
data packet was received.')
+
trreload = Test.AddTestRun("Reload config")
trreload.StillRunningAfter = ts
trreload.StillRunningAfter = server_foo
@@ -189,6 +237,7 @@ trreload.Processes.Default.ReturnCode = 0
# Should terminate on traffic_server (not tunnel)
tr = Test.AddTestRun("foo.com no Tunnel-test")
+tr.TimeOut = 30
tr.StillRunningAfter = ts
# Wait for the reload to complete by running the sni_reload_done test
tr.Processes.Default.StartBefore(server2,
ready=When.FileContains(ts.Disk.diags_log.Name, 'sni.yaml finished loading', 2))
@@ -196,7 +245,6 @@ tr.Processes.Default.Command = "curl -v --resolve
'foo.com:{0}:127.0.0.1' -k ht
tr.Processes.Default.Streams.All += Testers.ContainsExpression("Not Found on
Accelerato", "Terminates on on Traffic Server")
tr.Processes.Default.Streams.All += Testers.ContainsExpression("ATS",
"Terminate on Traffic Server")
tr.Processes.Default.Streams.All += Testers.ExcludesExpression("Could Not
Connect", "Curl attempt should have succeeded")
-tr.TimeOut = 30
# Should tunnel to server_bar
tr = Test.AddTestRun("bar.com Tunnel-test")