diff --git a/ci/rat-exclude.txt b/ci/rat-exclude.txt index 39ad1b11ca8..bb8926ef25c 100644 --- a/ci/rat-exclude.txt +++ b/ci/rat-exclude.txt @@ -24,6 +24,8 @@ blib/** **/*.default.in **/*.config **/*.gold +**/go.mod +**/go.sum **/*.hrw4u **/.gitignore **/.gitmodules diff --git a/include/iocore/net/quic/Mock.h b/include/iocore/net/quic/Mock.h index 95333ee2001..3332f82926f 100644 --- a/include/iocore/net/quic/Mock.h +++ b/include/iocore/net/quic/Mock.h @@ -28,6 +28,8 @@ #include "iocore/net/quic/QUICStreamAdapter.h" #include "iocore/net/quic/QUICStream.h" +#include + class MockQUICContext; using namespace std::literals; @@ -191,6 +193,11 @@ class MockQUICConnectionInfoProvider : public QUICConnectionInfoProvider { return negotiated_application_name_sv; } + + void + on_stream_updated() override + { + } }; class MockQUICStreamManager : public QUICStreamManager @@ -431,6 +438,11 @@ class MockQUICConnection : public QUICConnection return negotiated_application_name_sv; } + void + on_stream_updated() override + { + } + int _transmit_count = 0; int _retransmit_count = 0; Ptr _mutex; @@ -519,13 +531,19 @@ class MockQUICStreamAdapter : public QUICStreamAdapter Ptr _read(size_t len) override { - this->_sending_data_len -= len; - Ptr block = make_ptr(new_IOBufferBlock()); + len = std::min(len, this->_sending_data_len); + Ptr block = make_ptr(new_IOBufferBlock()); block->alloc(iobuffer_size_to_index(len, BUFFER_SIZE_INDEX_32K)); block->fill(len); return block; } + void + _consume(size_t len) override + { + this->_sending_data_len -= std::min(len, this->_sending_data_len); + } + private: size_t _sending_data_len = 0; size_t _total_sending_data_len = 0; diff --git a/include/iocore/net/quic/QUICConnection.h b/include/iocore/net/quic/QUICConnection.h index 96f8f25b895..5a91c10380d 100644 --- a/include/iocore/net/quic/QUICConnection.h +++ b/include/iocore/net/quic/QUICConnection.h @@ -60,6 +60,7 @@ class QUICConnectionInfoProvider virtual bool is_handshake_completed() const = 0; virtual QUICVersion negotiated_version() const = 0; virtual std::string_view negotiated_application_name() const = 0; + virtual void on_stream_updated() = 0; }; class QUICConnection : public QUICConnectionInfoProvider diff --git a/include/iocore/net/quic/QUICStream.h b/include/iocore/net/quic/QUICStream.h index e0cb94c8da0..ef84c9cb6aa 100644 --- a/include/iocore/net/quic/QUICStream.h +++ b/include/iocore/net/quic/QUICStream.h @@ -26,6 +26,7 @@ #include "tscore/List.h" #include "iocore/eventsystem/Event.h" +#include "iocore/eventsystem/IOBuffer.h" #include "iocore/net/quic/QUICConnection.h" #include "iocore/net/quic/QUICDebugNames.h" @@ -53,6 +54,7 @@ class QUICStream QUICStreamDirection direction() const; bool is_bidirectional() const; bool has_no_more_data() const; + bool has_data_to_send(); QUICOffset final_offset() const; @@ -66,6 +68,7 @@ class QUICStream * QUICApplication need to call one of these functions when it process VC_EVENT_* */ void on_read(); + void on_write(); void on_eos(); /** @@ -85,6 +88,9 @@ class QUICStream uint64_t _received_bytes = 0; uint64_t _sent_bytes = 0; bool _has_no_more_data = false; + Ptr _pending_send_block; + bool _pending_send_fin = false; + bool _sent_fin = false; }; class QUICStreamStateListener diff --git a/include/iocore/net/quic/QUICStreamAdapter.h b/include/iocore/net/quic/QUICStreamAdapter.h index 1bf36cbcc1a..87afe51a5b8 100644 --- a/include/iocore/net/quic/QUICStreamAdapter.h +++ b/include/iocore/net/quic/QUICStreamAdapter.h @@ -39,6 +39,7 @@ class QUICStreamAdapter virtual int64_t write(QUICOffset offset, const uint8_t *data, uint64_t data_length, bool fin) = 0; Ptr read(size_t len); + void consume(size_t len); virtual bool is_eos() = 0; virtual uint64_t unread_len() = 0; virtual uint64_t read_len() = 0; @@ -60,6 +61,7 @@ class QUICStreamAdapter virtual void notify_eos() = 0; protected: - virtual Ptr _read(size_t len) = 0; + virtual Ptr _read(size_t len) = 0; + virtual void _consume(size_t len) = 0; QUICStream &_stream; }; diff --git a/include/iocore/net/quic/QUICStreamVCAdapter.h b/include/iocore/net/quic/QUICStreamVCAdapter.h index ce27422560c..ce3e9327001 100644 --- a/include/iocore/net/quic/QUICStreamVCAdapter.h +++ b/include/iocore/net/quic/QUICStreamVCAdapter.h @@ -65,6 +65,7 @@ class QUICStreamVCAdapter : public VConnection, public QUICStreamAdapter protected: Ptr _read(size_t len) override; + void _consume(size_t len) override; VIO _read_vio; VIO _write_vio; diff --git a/include/iocore/net/quic/QUICTypes.h b/include/iocore/net/quic/QUICTypes.h index 706e807cadb..951226b054e 100644 --- a/include/iocore/net/quic/QUICTypes.h +++ b/include/iocore/net/quic/QUICTypes.h @@ -455,9 +455,9 @@ class QUICFiveTuple int protocol() const; private: - IpEndpoint _source; - IpEndpoint _destination; - int _protocol; + IpEndpoint _source{}; + IpEndpoint _destination{}; + int _protocol = 0; uint64_t _hash_code = 0; }; diff --git a/include/proxy/http2/Http2Stream.h b/include/proxy/http2/Http2Stream.h index bc4b0743c51..67e8525d5a3 100644 --- a/include/proxy/http2/Http2Stream.h +++ b/include/proxy/http2/Http2Stream.h @@ -163,6 +163,7 @@ class Http2Stream : public ProxyTransaction void increment_data_length(uint64_t length); bool payload_length_is_valid() const; bool is_write_vio_done() const; + int64_t write_vio_ntodo() const; void update_sent_count(unsigned num_bytes); Http2StreamId get_id() const; Http2StreamState get_state() const; @@ -174,6 +175,7 @@ class Http2Stream : public ProxyTransaction void set_receive_headers(HTTPHdr &h2_headers); void reset_receive_headers(); void reset_send_headers(); + void set_sent_request_method(int method); MIOBuffer *read_vio_writer() const; int64_t read_vio_read_avail(); bool is_read_enabled() const; @@ -214,6 +216,7 @@ class Http2Stream : public ProxyTransaction Http2StreamId _id = -1; Http2StreamState _state = Http2StreamState::HTTP2_STREAM_STATE_IDLE; int64_t _http_sm_id = -1; + int _sent_request_method{-1}; HTTPHdr _receive_header; #if TS_USE_MALLOC_ALLOCATOR @@ -314,6 +317,12 @@ Http2Stream::is_write_vio_done() const return this->write_vio.ntodo() == 0; } +inline int64_t +Http2Stream::write_vio_ntodo() const +{ + return this->write_vio.ntodo(); +} + inline void Http2Stream::update_sent_count(unsigned num_bytes) { @@ -389,6 +398,12 @@ Http2Stream::reset_send_headers() this->_send_header.create(HTTPType::RESPONSE); } +inline void +Http2Stream::set_sent_request_method(int method) +{ + _sent_request_method = method; +} + // Check entire DATA payload length if content-length: header exists inline void Http2Stream::increment_data_length(uint64_t length) @@ -405,9 +420,9 @@ Http2Stream::payload_length_is_valid() const // Skip Content-Length check on [RFC 7230] 3.3.2 conditions bool is_payload_precluded = - this->is_outbound_connection() && (_send_header.method_get_wksidx() == HTTP_WKSIDX_HEAD || - (_send_header.method_get_wksidx() == HTTP_WKSIDX_GET && _send_header.presence(mask) && - _receive_header.status_get() == HTTPStatus::NOT_MODIFIED)); + this->is_outbound_connection() && + (_sent_request_method == HTTP_WKSIDX_HEAD || (_sent_request_method == HTTP_WKSIDX_GET && _send_header.presence(mask) && + _receive_header.status_get() == HTTPStatus::NOT_MODIFIED)); if (content_length != 0 && !is_payload_precluded && content_length != data_length) { Warning("Bad payload length content_length=%d data_legnth=%d session_id=%" PRId64, content_length, diff --git a/include/proxy/http3/Http3App.h b/include/proxy/http3/Http3App.h index 80c05cf12c9..70e7377beff 100644 --- a/include/proxy/http3/Http3App.h +++ b/include/proxy/http3/Http3App.h @@ -79,6 +79,7 @@ class Http3App : public QUICApplication void _handle_bidi_stream_on_write_complete(int event, VIO *vio); void _handle_bidi_stream_on_eos(int event, VIO *vio); + void _handle_error(const Http3Error &error); void _set_qpack_stream(Http3StreamType type, QUICStreamVCAdapter *adapter); QUICStreamVCAdapter::IOInfo &_get_stream_info(QUICStreamId stream_id); diff --git a/include/proxy/http3/Http3ProtocolEnforcer.h b/include/proxy/http3/Http3ProtocolEnforcer.h index ee291bdeca1..e9801f6802b 100644 --- a/include/proxy/http3/Http3ProtocolEnforcer.h +++ b/include/proxy/http3/Http3ProtocolEnforcer.h @@ -37,4 +37,5 @@ class Http3ProtocolEnforcer : public Http3FrameHandler private: bool _is_first_frame_received_on_control = false; + bool _is_headers_frame_received = false; }; diff --git a/include/proxy/http3/Http3Session.h b/include/proxy/http3/Http3Session.h index 6528b1f0e6f..4785390eba7 100644 --- a/include/proxy/http3/Http3Session.h +++ b/include/proxy/http3/Http3Session.h @@ -56,6 +56,9 @@ class HQSession : public ProxySession void remove_transaction(HQTransaction *trans); HQTransaction *get_transaction(QUICStreamId); +protected: + void _close_transactions(); + private: // this should be unordered map? Queue _transaction_list; diff --git a/include/proxy/http3/Http3StreamDataVIOAdaptor.h b/include/proxy/http3/Http3StreamDataVIOAdaptor.h index 4eff081d9d7..8968ab65cc2 100644 --- a/include/proxy/http3/Http3StreamDataVIOAdaptor.h +++ b/include/proxy/http3/Http3StreamDataVIOAdaptor.h @@ -42,7 +42,9 @@ class Http3StreamDataVIOAdaptor : public Http3FrameHandler bool has_data(); private: - VIO *_sink_vio = nullptr; - int64_t _total_data_length = 0; - MIOBuffer *_buffer; + VIO *_sink_vio = nullptr; + int64_t _total_data_length = 0; + MIOBuffer *_buffer = nullptr; + IOBufferReader *_reader = nullptr; + bool _finalized = false; }; diff --git a/include/proxy/http3/Http3Transaction.h b/include/proxy/http3/Http3Transaction.h index 1b0eb48806c..a17eb6d4bf7 100644 --- a/include/proxy/http3/Http3Transaction.h +++ b/include/proxy/http3/Http3Transaction.h @@ -29,6 +29,8 @@ #include "proxy/http3/Http3FrameDispatcher.h" #include "proxy/http3/Http3FrameCollector.h" +#include + class QUICStreamIO; class HQSession; class Http09Session; @@ -36,6 +38,7 @@ class Http3Session; class Http3HeaderFramer; class Http3DataFramer; class Http3HeaderVIOAdaptor; +class Http3ProtocolEnforcer; class Http3StreamDataVIOAdaptor; class HQTransaction : public ProxyTransaction @@ -53,6 +56,8 @@ class HQTransaction : public ProxyTransaction void transaction_done() override; void release() override; int get_transaction_id() const override; + void stream_closed(); + void set_stream_cleanup(std::function cleanup); void increment_transactions_stat() override; void decrement_transactions_stat() override; @@ -81,6 +86,7 @@ class HQTransaction : public ProxyTransaction void _schedule_read_complete_event(); void _unschedule_read_complete_event(); void _close_read_complete_event(Event *e); + void _schedule_read_event(); void _schedule_write_ready_event(); void _unschedule_write_ready_event(); void _close_write_ready_event(Event *e); @@ -90,12 +96,14 @@ class HQTransaction : public ProxyTransaction void _signal_event(int event, Event *e); void _signal_read_event(); void _signal_write_event(); + bool _is_write_buffer_flushed(); void _delete_if_possible(); EThread *_thread = nullptr; MIOBuffer _read_vio_buf{BUFFER_SIZE_INDEX_4K}; QUICStreamVCAdapter::IOInfo &_info; + QUICStreamId _stream_id = 0; size_t _sent_bytes = 0; @@ -106,7 +114,12 @@ class HQTransaction : public ProxyTransaction Event *_write_ready_event = nullptr; Event *_write_complete_event = nullptr; - bool _transaction_done = false; + bool _transaction_done = false; + bool _event_handler_active = false; + bool _closed = false; + bool _stream_closed = false; + + std::function _stream_cleanup; }; class Http3Transaction : public HQTransaction @@ -121,6 +134,7 @@ class Http3Transaction : public HQTransaction int state_stream_closed(int event, Event *data) override; void do_io_close(int lerrno = -1) override; + void on_header_decode_complete(); bool is_response_header_sent() const; bool is_response_body_sent() const; @@ -131,14 +145,16 @@ class Http3Transaction : public HQTransaction private: int64_t _process_read_vio() override; int64_t _process_write_vio() override; + void _handle_error(const Http3Error &error); // These are for HTTP/3 Http3FrameDispatcher _frame_dispatcher; Http3FrameCollector _frame_collector; - Http3FrameGenerator *_header_framer = nullptr; - Http3FrameGenerator *_data_framer = nullptr; - Http3HeaderVIOAdaptor *_header_handler = nullptr; - Http3StreamDataVIOAdaptor *_data_handler = nullptr; + Http3ProtocolEnforcer *_protocol_enforcer = nullptr; + Http3FrameGenerator *_header_framer = nullptr; + Http3FrameGenerator *_data_framer = nullptr; + Http3HeaderVIOAdaptor *_header_handler = nullptr; + Http3StreamDataVIOAdaptor *_data_handler = nullptr; }; /** diff --git a/include/proxy/http3/Http3Types.h b/include/proxy/http3/Http3Types.h index 9d98578827c..5eb6eac2722 100644 --- a/include/proxy/http3/Http3Types.h +++ b/include/proxy/http3/Http3Types.h @@ -62,6 +62,7 @@ enum class Http3FrameType : uint64_t { X_RESERVED_4 = 0x09, MAX_PUSH_ID = 0x0D, X_MAX_DEFINED = 0x0D, + RESERVED = 0x21, UNKNOWN = 0x0E, }; diff --git a/include/proxy/logging/TransactionLogData.h b/include/proxy/logging/TransactionLogData.h index b65388d0a17..5649eb6265c 100644 --- a/include/proxy/logging/TransactionLogData.h +++ b/include/proxy/logging/TransactionLogData.h @@ -27,6 +27,8 @@ #include "proxy/hdrs/HTTP.h" #include "tscore/ink_inet.h" +#include +#include #include #include diff --git a/src/iocore/net/P_QUICNetVConnection.h b/src/iocore/net/P_QUICNetVConnection.h index e6fec2b8ea5..89af3685e4e 100644 --- a/src/iocore/net/P_QUICNetVConnection.h +++ b/src/iocore/net/P_QUICNetVConnection.h @@ -129,6 +129,7 @@ class QUICNetVConnection : public UnixNetVConnection, bool is_at_anti_amplification_limit() const override; bool is_address_validation_completed() const override; bool is_handshake_completed() const override; + void on_stream_updated() override; // QUICSupport QUICConnection *get_quic_connection() override; diff --git a/src/iocore/net/P_UDPNet.h b/src/iocore/net/P_UDPNet.h index 04af410c371..42438b2c3ba 100644 --- a/src/iocore/net/P_UDPNet.h +++ b/src/iocore/net/P_UDPNet.h @@ -56,7 +56,7 @@ class UDPNetProcessorInternal : public UDPNetProcessor private: void read_single_message_from_net(UDPNetHandler *nh, UDPConnection *uc); - void read_multiple_messages_from_net(UDPNetHandler *nh, UDPConnection *xuc); + bool read_multiple_messages_from_net(UDPNetHandler *nh, UDPConnection *xuc); }; extern UDPNetProcessorInternal udpNetInternal; diff --git a/src/iocore/net/QUICNetVConnection.cc b/src/iocore/net/QUICNetVConnection.cc index d113ae593e1..18a85913739 100644 --- a/src/iocore/net/QUICNetVConnection.cc +++ b/src/iocore/net/QUICNetVConnection.cc @@ -37,6 +37,7 @@ #include #include +#include namespace { @@ -390,8 +391,28 @@ QUICNetVConnection::stream_manager() } void -QUICNetVConnection::close_quic_connection(QUICConnectionErrorUPtr /* error ATS_UNUSED */) +QUICNetVConnection::close_quic_connection(QUICConnectionErrorUPtr error) { + if (this->_quiche_con == nullptr || quiche_conn_is_closed(this->_quiche_con) || quiche_conn_is_draining(this->_quiche_con)) { + return; + } + + const bool is_app_error = error != nullptr && error->cls == QUICErrorClass::APPLICATION; + const uint64_t code = error == nullptr ? static_cast(QUICTransErrorCode::NO_ERROR) : error->code; + const uint8_t *reason = nullptr; + size_t reason_len = 0; + + if (error != nullptr && error->msg != nullptr) { + reason = reinterpret_cast(error->msg); + reason_len = strlen(error->msg); + } + + if (quiche_conn_close(this->_quiche_con, is_app_error, code, reason, reason_len) != 0) { + QUICConDebug("failed to close QUIC connection with code %" PRIu64, code); + return; + } + + this->_schedule_packet_write_ready(false); } void @@ -500,6 +521,12 @@ QUICNetVConnection::negotiated_application_name() const return std::string_view(reinterpret_cast(name), name_len); } +void +QUICNetVConnection::on_stream_updated() +{ + this->_schedule_packet_write_ready(false); +} + bool QUICNetVConnection::is_closed() const { @@ -690,7 +717,6 @@ QUICNetVConnection::_handle_write_ready() while (written + max_udp_payload_size <= quantum) { res = quiche_conn_send(this->_quiche_con, reinterpret_cast(udp_payload->end()) + written, max_udp_payload_size, &send_info); - #ifdef HAVE_SO_TXTIME if (written == 0) { memcpy(&send_at_hint, &send_info.at, sizeof(struct timespec)); diff --git a/src/iocore/net/UnixUDPNet.cc b/src/iocore/net/UnixUDPNet.cc index 513b074c775..48f329be46c 100644 --- a/src/iocore/net/UnixUDPNet.cc +++ b/src/iocore/net/UnixUDPNet.cc @@ -70,7 +70,8 @@ EventType ET_UDP; namespace { #ifdef HAVE_RECVMMSG -const uint32_t MAX_RECEIVE_MSG_PER_CALL{16}; //< VLEN parameter for the recvmmsg call. +const uint32_t MAX_RECEIVE_MSG_PER_CALL{16}; //< VLEN parameter for the recvmmsg call. +const uint32_t MAX_RECEIVE_MSG_BATCHES_PER_EVENT{8}; //< Maximum number of full recvmmsg batches per event. #endif DbgCtl dbg_ctl_udpnet{"udpnet"}; @@ -516,7 +517,7 @@ UDPNetProcessorInternal::read_single_message_from_net(UDPNetHandler *nh, UDPConn } #ifdef HAVE_RECVMMSG -void +bool UDPNetProcessorInternal::read_multiple_messages_from_net(UDPNetHandler *nh, UDPConnection *xuc) { UnixUDPConnection *uc = static_cast(xuc); @@ -575,7 +576,7 @@ UDPNetProcessorInternal::read_multiple_messages_from_net(UDPNetHandler *nh, UDPC if (return_val <= 0) { Dbg(dbg_ctl_udp_read, "Done. recvmmsg() ret is %d, errno %s", return_val, strerror(errno)); - return; + return false; } Dbg(dbg_ctl_udp_read, "recvmmsg() read %d packets", return_val); @@ -593,7 +594,7 @@ UDPNetProcessorInternal::read_multiple_messages_from_net(UDPNetHandler *nh, UDPC if (mhdr.msg_namelen <= 0) { Dbg(dbg_ctl_udp_read, "Unable to get remote address from recvmmsg() for fd: %d", uc->getFd()); - return; + return false; } toaddr[packet_num].ss_family = AF_UNSPEC; @@ -666,6 +667,8 @@ UDPNetProcessorInternal::read_multiple_messages_from_net(UDPNetHandler *nh, UDPC nh->udp_callbacks.enqueue(uc); uc->onCallbackQueue = 1; } + + return return_val == static_cast(MAX_RECEIVE_MSG_PER_CALL); } #endif @@ -673,7 +676,7 @@ void UDPNetProcessorInternal::udp_read_from_net(UDPNetHandler *nh, UDPConnection *xuc) { #if HAVE_RECVMMSG - read_multiple_messages_from_net(nh, xuc); + for (uint32_t batch = 0; batch < MAX_RECEIVE_MSG_BATCHES_PER_EVENT && read_multiple_messages_from_net(nh, xuc); ++batch) {} #else read_single_message_from_net(nh, xuc); #endif diff --git a/src/iocore/net/quic/QUICStream.cc b/src/iocore/net/quic/QUICStream.cc index e221df25163..cdc427c9af8 100644 --- a/src/iocore/net/quic/QUICStream.cc +++ b/src/iocore/net/quic/QUICStream.cc @@ -24,7 +24,8 @@ #include "iocore/net/quic/QUICStream.h" #include "iocore/net/quic/QUICStreamAdapter.h" -constexpr uint32_t MAX_STREAM_FRAME_OVERHEAD = 24; +constexpr uint32_t MAX_STREAM_FRAME_OVERHEAD = 24; +constexpr size_t MAX_STREAM_SEND_BYTES_PER_EVENT = 16 * 1024; QUICStream::QUICStream(QUICConnectionInfoProvider *cinfo, QUICStreamId sid) : _connection_info(cinfo), _id(sid) {} @@ -60,6 +61,22 @@ QUICStream::has_no_more_data() const return this->_has_no_more_data; } +bool +QUICStream::has_data_to_send() +{ + if (this->_pending_send_block) { + return true; + } + if (this->_adapter == nullptr) { + return false; + } + + const bool has_buffered_data = this->_adapter->unread_len() > 0; + const bool needs_fin = !this->_sent_fin && this->_adapter->is_eos() && this->_adapter->total_len() == this->_sent_bytes; + + return has_buffered_data || needs_fin; +} + void QUICStream::set_io_adapter(QUICStreamAdapter *adapter) { @@ -87,6 +104,14 @@ QUICStream::on_read() { } +void +QUICStream::on_write() +{ + if (this->_connection_info != nullptr) { + this->_connection_info->on_stream_updated(); + } +} + void QUICStream::on_eos() { @@ -117,21 +142,63 @@ QUICStream::send_data(quiche_conn *quiche_con) ssize_t len = 0; [[maybe_unused]] ErrorCode error_code{0}; // Only set if QUICHE_ERR_STREAM_STOPPED(-15) or QUICHE_ERR_STREAM_RESET(-16) are // returned by quiche_conn_stream_send. + size_t written_this_event = 0; - len = quiche_conn_stream_capacity(quiche_con, this->_id); - if (len <= 0) { - return; - } - Ptr block = this->_adapter->read(len); - if (this->_adapter->total_len() == this->_sent_bytes + block->size()) { - fin = true; - } - if (block->size() > 0 || fin) { - ssize_t written_len = - quiche_conn_stream_send(quiche_con, this->_id, reinterpret_cast(block->start()), block->size(), fin, &error_code); - if (written_len >= 0) { - this->_sent_bytes += written_len; + while (written_this_event < MAX_STREAM_SEND_BYTES_PER_EVENT) { + len = quiche_conn_stream_capacity(quiche_con, this->_id); + if (len <= 0) { + return; + } + + if (!this->_pending_send_block) { + size_t read_len = std::min(static_cast(len), MAX_STREAM_SEND_BYTES_PER_EVENT - written_this_event); + this->_pending_send_block = this->_adapter->read(read_len); + if (!this->_pending_send_block) { + if (!this->_sent_fin && this->_adapter->is_eos() && this->_adapter->total_len() == this->_sent_bytes) { + static constexpr uint8_t empty_data = 0; + ssize_t written_len = quiche_conn_stream_send(quiche_con, this->_id, &empty_data, 0, true, &error_code); + if (written_len >= 0) { + this->_sent_fin = true; + } + } + this->_adapter->encourge_write(); + return; + } + this->_pending_send_fin = this->_adapter->total_len() == this->_sent_bytes + this->_pending_send_block->size(); } + + Ptr block = this->_pending_send_block; + fin = this->_pending_send_fin; + if (block->size() == 0 && !fin) { + this->_pending_send_block = nullptr; + this->_pending_send_fin = false; + this->_adapter->encourge_write(); + continue; + } + + if (block->size() > 0 || fin) { + ssize_t written_len = quiche_conn_stream_send(quiche_con, this->_id, reinterpret_cast(block->start()), + block->size(), fin, &error_code); + if (written_len >= 0) { + this->_adapter->consume(written_len); + this->_sent_bytes += written_len; + written_this_event += written_len; + if (written_len >= block->size()) { + this->_pending_send_block = nullptr; + this->_pending_send_fin = false; + this->_sent_fin = fin; + } else { + block->consume(written_len); + return; + } + if (!this->has_data_to_send()) { + this->_adapter->encourge_write(); + return; + } + continue; + } + } + this->_adapter->encourge_write(); + return; } - this->_adapter->encourge_write(); } diff --git a/src/iocore/net/quic/QUICStreamAdapter.cc b/src/iocore/net/quic/QUICStreamAdapter.cc index 9992b8a661e..2e49214ad50 100644 --- a/src/iocore/net/quic/QUICStreamAdapter.cc +++ b/src/iocore/net/quic/QUICStreamAdapter.cc @@ -26,7 +26,12 @@ Ptr QUICStreamAdapter::read(size_t len) { - auto ret = this->_read(len); + return this->_read(len); +} + +void +QUICStreamAdapter::consume(size_t len) +{ + this->_consume(len); this->_stream.on_read(); - return ret; } diff --git a/src/iocore/net/quic/QUICStreamVCAdapter.cc b/src/iocore/net/quic/QUICStreamVCAdapter.cc index d79abe51db9..225bef63c4a 100644 --- a/src/iocore/net/quic/QUICStreamVCAdapter.cc +++ b/src/iocore/net/quic/QUICStreamVCAdapter.cc @@ -24,6 +24,8 @@ #include "iocore/eventsystem/VConnection.h" #include "iocore/net/quic/QUICStreamVCAdapter.h" +#include + QUICStreamVCAdapter::QUICStreamVCAdapter(QUICStream &stream) : VConnection(new_ProxyMutex()), QUICStreamAdapter(stream) { SET_HANDLER(&QUICStreamVCAdapter::state_stream_open); @@ -84,18 +86,45 @@ QUICStreamVCAdapter::_read(size_t len) SCOPED_MUTEX_LOCK(lock, this->_write_vio.mutex, this_ethread()); IOBufferReader *reader = this->_write_vio.get_reader(); - block = make_ptr(reader->get_current_block()->clone()); + if (reader == nullptr || reader->get_current_block() == nullptr || reader->block_read_avail() <= 0) { + return block; + } + + const size_t read_len = std::min(len, static_cast(reader->block_read_avail())); + block = make_ptr(reader->get_current_block()->clone()); if (block->size()) { block->consume(reader->start_offset); - block->_end = std::min(block->start() + len, block->_buf_end); - this->_write_vio.ndone += block->size(); + block->_end = block->start() + read_len; + } + if (block->size() == 0) { + block = nullptr; } - reader->consume(block->size()); } return block; } +void +QUICStreamVCAdapter::_consume(size_t len) +{ + if (len == 0 || this->_write_vio.op != VIO::WRITE) { + return; + } + + SCOPED_MUTEX_LOCK(lock, this->_write_vio.mutex, this_ethread()); + + IOBufferReader *reader = this->_write_vio.get_reader(); + if (reader == nullptr) { + return; + } + + const size_t consume_len = std::min(len, static_cast(std::max(reader->read_avail(), 0))); + if (consume_len > 0) { + reader->consume(consume_len); + this->_write_vio.ndone += consume_len; + } +} + bool QUICStreamVCAdapter::is_eos() { @@ -119,7 +148,8 @@ QUICStreamVCAdapter::unread_len() { if (this->_write_vio.op == VIO::WRITE) { SCOPED_MUTEX_LOCK(lock, this->_write_vio.mutex, this_ethread()); - return this->_write_vio.get_reader()->block_read_avail(); + IOBufferReader *reader = this->_write_vio.get_reader(); + return reader == nullptr ? 0 : reader->block_read_avail(); } else { return 0; } @@ -321,23 +351,33 @@ QUICStreamVCAdapter::do_io_shutdown(ShutdownHowTo_t /* howto ATS_UNUSED */) } void -QUICStreamVCAdapter::reenable(VIO * /* vio ATS_UNUSED */) +QUICStreamVCAdapter::reenable(VIO *vio) { - // TODO We probably need to tell QUICStream that the application consumed received data - // to update receive window here. In other words, we should not update receive window - // until the application consume data. + if (vio == nullptr || vio->op != VIO::WRITE) { + // TODO We probably need to tell QUICStream that the application consumed received data + // to update receive window here. In other words, we should not update receive window + // until the application consume data. + return; + } + + const bool has_buffered_data = vio->get_reader() != nullptr && vio->get_reader()->read_avail() > 0; + const bool needs_fin = vio->nbytes != INT64_MAX && vio->ntodo() == 0; + if (has_buffered_data || needs_fin) { + this->stream().on_write(); + } } bool QUICStreamVCAdapter::is_readable() { - return this->stream().direction() != QUICStreamDirection::SEND && _read_vio.nbytes != _read_vio.ndone; + return this->stream().direction() != QUICStreamDirection::SEND && this->_read_vio.op == VIO::READ && + this->_read_vio.nbytes != this->_read_vio.ndone; } bool QUICStreamVCAdapter::is_writable() { - return this->stream().direction() != QUICStreamDirection::RECEIVE && _write_vio.nbytes != _read_vio.ndone; + return this->stream().direction() != QUICStreamDirection::RECEIVE; } int diff --git a/src/proxy/http2/Http2ConnectionState.cc b/src/proxy/http2/Http2ConnectionState.cc index 9c80aefe295..65cccf14604 100644 --- a/src/proxy/http2/Http2ConnectionState.cc +++ b/src/proxy/http2/Http2ConnectionState.cc @@ -2275,6 +2275,7 @@ Http2ConnectionState::send_a_data_frame(Http2Stream *stream, size_t &payload_len uint8_t flags = 0x00; IOBufferReader *resp_reader = stream->get_data_reader_for_send(); + bool last_write_vio_payload{false}; SCOPED_MUTEX_LOCK(stream_lock, stream->mutex, this_ethread()); @@ -2307,6 +2308,16 @@ Http2ConnectionState::send_a_data_frame(Http2Stream *stream, size_t &payload_len } else { payload_length = resp_reader->read_avail(); } + const int64_t remaining_write = stream->write_vio_ntodo(); + if (remaining_write != INT64_MAX) { + if (remaining_write > 0) { + last_write_vio_payload = payload_length >= static_cast(remaining_write); + payload_length = std::min(payload_length, static_cast(remaining_write)); + } else { + last_write_vio_payload = true; + payload_length = 0; + } + } } else { payload_length = 0; } @@ -2334,7 +2345,8 @@ Http2ConnectionState::send_a_data_frame(Http2Stream *stream, size_t &payload_len return Http2SendDataFrameResult::NO_PAYLOAD; } - if (stream->is_write_vio_done() && !resp_reader->is_read_avail_more_than(payload_length) && !stream->expect_send_trailer()) { + if (stream->is_write_vio_done() && (last_write_vio_payload || !resp_reader->is_read_avail_more_than(payload_length)) && + !stream->expect_send_trailer()) { Http2StreamDebug(this->session, stream->get_id(), "End of Data Frame"); flags |= HTTP2_FLAGS_DATA_END_STREAM; } @@ -2456,6 +2468,7 @@ Http2ConnectionState::send_headers_frame(Http2Stream *stream) flags |= HTTP2_FLAGS_HEADERS_END_HEADERS; if (stream->is_outbound_connection()) { // Will be sending a request_header int method = send_hdr->method_get_wksidx(); + stream->set_sent_request_method(method); // Set END_STREAM on request headers for POST, etc. methods combined with // an explicit length 0. Some origins RST on request headers with diff --git a/src/proxy/http3/Http3App.cc b/src/proxy/http3/Http3App.cc index 09d349a6a69..1f3d1a3172a 100644 --- a/src/proxy/http3/Http3App.cc +++ b/src/proxy/http3/Http3App.cc @@ -103,6 +103,15 @@ Http3App::start() // } } +void +Http3App::_handle_error(const Http3Error &error) +{ + if (error.cls == Http3ErrorClass::CONNECTION) { + this->_qc->close_quic_connection( + std::make_unique(QUICErrorClass::APPLICATION, static_cast(error.code))); + } +} + void Http3App::on_stream_open(QUICStream &stream) { @@ -131,7 +140,15 @@ Http3App::on_stream_open(QUICStream &stream) void Http3App::on_stream_close(QUICStream &stream) { - this->_streams.erase(stream.id()); + QUICStreamId const stream_id = stream.id(); + + if (auto *txn = this->_ssn->get_transaction(stream_id); txn != nullptr) { + SCOPED_MUTEX_LOCK(lock, txn->mutex, this_ethread()); + txn->set_stream_cleanup([this, stream_id]() { this->_streams.erase(stream_id); }); + txn->stream_closed(); + } else { + this->_streams.erase(stream_id); + } } int @@ -286,6 +303,10 @@ Http3App::_handle_uni_stream_on_read_ready(int /* event */, VIO *vio) default: break; } + + if (error && error->cls != Http3ErrorClass::UNDEFINED) { + this->_handle_error(*error); + } } void diff --git a/src/proxy/http3/Http3DebugNames.cc b/src/proxy/http3/Http3DebugNames.cc index 4208f6c4a43..1ea186fa19b 100644 --- a/src/proxy/http3/Http3DebugNames.cc +++ b/src/proxy/http3/Http3DebugNames.cc @@ -48,6 +48,8 @@ Http3DebugNames::frame_type(Http3FrameType type) return "X_RESERVED_3"; case Http3FrameType::X_RESERVED_4: return "X_RESERVED_4"; + case Http3FrameType::RESERVED: + return "RESERVED"; case Http3FrameType::UNKNOWN: default: return "UNKNOWN"; diff --git a/src/proxy/http3/Http3Frame.cc b/src/proxy/http3/Http3Frame.cc index 64b0a0a4651..6fc7d5b5441 100644 --- a/src/proxy/http3/Http3Frame.cc +++ b/src/proxy/http3/Http3Frame.cc @@ -42,6 +42,13 @@ constexpr int HEADER_OVERHEAD = 10; // This should work as long as a payloa DbgCtl dbg_ctl_http3_frame_factory{"http3_frame_factory"}; +bool +is_reserved_frame_type(uint64_t type) +{ + return type >= static_cast(Http3FrameType::RESERVED) && + (type - static_cast(Http3FrameType::RESERVED)) % 0x1f == 0; +} + } // end anonymous namespace // @@ -64,6 +71,8 @@ Http3Frame::type(const uint8_t *buf, size_t buf_len) ink_assert(ret != 1); if (type <= static_cast(Http3FrameType::X_MAX_DEFINED)) { return static_cast(type); + } else if (is_reserved_frame_type(type)) { + return Http3FrameType::RESERVED; } else { return Http3FrameType::UNKNOWN; } @@ -143,8 +152,12 @@ Http3Frame::length() const Http3FrameType Http3Frame::type() const { - if (static_cast(this->_type) <= static_cast(Http3FrameType::X_MAX_DEFINED)) { + const auto type = static_cast(this->_type); + + if (type <= static_cast(Http3FrameType::X_MAX_DEFINED)) { return this->_type; + } else if (is_reserved_frame_type(type)) { + return Http3FrameType::RESERVED; } else { return Http3FrameType::UNKNOWN; } diff --git a/src/proxy/http3/Http3HeaderVIOAdaptor.cc b/src/proxy/http3/Http3HeaderVIOAdaptor.cc index 9d3e256b7f6..7f489d4fa28 100644 --- a/src/proxy/http3/Http3HeaderVIOAdaptor.cc +++ b/src/proxy/http3/Http3HeaderVIOAdaptor.cc @@ -139,7 +139,8 @@ Http3HeaderVIOAdaptor::_on_qpack_decode_complete() // or // c). Add interface to HttpSM to handle HTTPHdr directly int bufindex; - int dumpoffset = 0; + int dumpoffset = 0; + int64_t header_length = 0; int done, tmp; IOBufferBlock *block; do { @@ -150,14 +151,19 @@ Http3HeaderVIOAdaptor::_on_qpack_decode_complete() writer->add_block(); block = writer->get_current_block(); } - done = this->_header.print(block->end(), block->write_avail(), &bufindex, &tmp); - dumpoffset += bufindex; + done = this->_header.print(block->end(), block->write_avail(), &bufindex, &tmp); + dumpoffset += bufindex; + header_length += bufindex; writer->fill(bufindex); if (!done) { writer->add_block(); } } while (!done); - this->_is_complete = true; + this->_sink_vio->ndone += header_length; + this->_is_complete = true; + if (auto *transaction = dynamic_cast(this->_txn); transaction != nullptr) { + transaction->on_header_decode_complete(); + } return 1; } diff --git a/src/proxy/http3/Http3ProtocolEnforcer.cc b/src/proxy/http3/Http3ProtocolEnforcer.cc index 9f7e8a8963a..a37cb543ae4 100644 --- a/src/proxy/http3/Http3ProtocolEnforcer.cc +++ b/src/proxy/http3/Http3ProtocolEnforcer.cc @@ -30,7 +30,7 @@ Http3ProtocolEnforcer::interests() return {Http3FrameType::DATA, Http3FrameType::HEADERS, Http3FrameType::X_RESERVED_1, Http3FrameType::CANCEL_PUSH, Http3FrameType::SETTINGS, Http3FrameType::PUSH_PROMISE, Http3FrameType::X_RESERVED_2, Http3FrameType::GOAWAY, Http3FrameType::X_RESERVED_3, Http3FrameType::X_RESERVED_4, Http3FrameType::MAX_PUSH_ID, Http3FrameType::X_MAX_DEFINED, - Http3FrameType::UNKNOWN}; + Http3FrameType::RESERVED, Http3FrameType::UNKNOWN}; } Http3ErrorUPtr @@ -47,9 +47,8 @@ Http3ProtocolEnforcer::handle_frame(std::shared_ptr frame, Htt "only one SETTINGS frame is allowed per the control stream"); } else if (f_type == Http3FrameType::DATA || f_type == Http3FrameType::HEADERS || f_type == Http3FrameType::X_RESERVED_1 || f_type == Http3FrameType::X_RESERVED_2 || f_type == Http3FrameType::X_RESERVED_3) { - std::string error_msg = Http3DebugNames::frame_type(f_type); - error_msg.append(" frame is not allowed on control stream"); - error = std::make_unique(Http3ErrorClass::CONNECTION, Http3ErrorCode::H3_FRAME_UNEXPECTED, error_msg.c_str()); + error = std::make_unique(Http3ErrorClass::CONNECTION, Http3ErrorCode::H3_FRAME_UNEXPECTED, + "frame is not allowed on control stream"); } if (!this->_is_first_frame_received_on_control) { this->_is_first_frame_received_on_control = true; @@ -57,9 +56,13 @@ Http3ProtocolEnforcer::handle_frame(std::shared_ptr frame, Htt } else { if (f_type == Http3FrameType::X_RESERVED_1 || f_type == Http3FrameType::X_RESERVED_2 || f_type == Http3FrameType::X_RESERVED_3) { - std::string error_msg = Http3DebugNames::frame_type(f_type); - error_msg.append(" frame is not allowed on any stream"); - error = std::make_unique(Http3ErrorClass::CONNECTION, Http3ErrorCode::H3_FRAME_UNEXPECTED, error_msg.c_str()); + error = std::make_unique(Http3ErrorClass::CONNECTION, Http3ErrorCode::H3_FRAME_UNEXPECTED, + "frame is not allowed on any stream"); + } else if (!this->_is_headers_frame_received && f_type == Http3FrameType::DATA) { + error = std::make_unique(Http3ErrorClass::CONNECTION, Http3ErrorCode::H3_FRAME_UNEXPECTED, + "DATA frame is not allowed before HEADERS"); + } else if (f_type == Http3FrameType::HEADERS) { + this->_is_headers_frame_received = true; } } diff --git a/src/proxy/http3/Http3Session.cc b/src/proxy/http3/Http3Session.cc index 0d33c9e63d8..c4569c16330 100644 --- a/src/proxy/http3/Http3Session.cc +++ b/src/proxy/http3/Http3Session.cc @@ -39,6 +39,8 @@ HQSession::HQSession(NetVConnection *vc) : ProxySession(vc) HQSession::~HQSession() { + this->_close_transactions(); + // Transactions should be deleted first before HQSession gets deleted. ink_assert(this->_transaction_list.head == nullptr); } @@ -59,6 +61,17 @@ HQSession::remove_transaction(HQTransaction *trans) return; } +void +HQSession::_close_transactions() +{ + while (this->_transaction_list.head != nullptr) { + auto *transaction = this->_transaction_list.head; + + transaction->do_io_close(); + delete transaction; + } +} + const char * HQSession::get_protocol_string() const { @@ -162,9 +175,11 @@ HQSession::main_event_handler(int event, void *edata) case VC_EVENT_ERROR: case VC_EVENT_EOS: this->do_io_close(); - for (HQTransaction *t = this->_transaction_list.head; t; t = static_cast(t->link.next)) { + for (HQTransaction *t = this->_transaction_list.head; t != nullptr;) { + HQTransaction *next = static_cast(t->link.next); SCOPED_MUTEX_LOCK(lock, t->mutex, this_ethread()); t->handleEvent(event, edata); + t = next; } break; } @@ -186,6 +201,7 @@ Http3Session::Http3Session(NetVConnection *vc) : HQSession(vc) Http3Session::~Http3Session() { + this->_close_transactions(); this->_vc = nullptr; delete this->_local_qpack; delete this->_remote_qpack; diff --git a/src/proxy/http3/Http3StreamDataVIOAdaptor.cc b/src/proxy/http3/Http3StreamDataVIOAdaptor.cc index 7f19c277d9a..296763972b8 100644 --- a/src/proxy/http3/Http3StreamDataVIOAdaptor.cc +++ b/src/proxy/http3/Http3StreamDataVIOAdaptor.cc @@ -24,10 +24,14 @@ #include "proxy/http3/Http3StreamDataVIOAdaptor.h" #include "iocore/eventsystem/VIO.h" -Http3StreamDataVIOAdaptor::Http3StreamDataVIOAdaptor(VIO *sink) : _sink_vio(sink), _buffer(new_MIOBuffer(BUFFER_SIZE_INDEX_4K)) {} +Http3StreamDataVIOAdaptor::Http3StreamDataVIOAdaptor(VIO *sink) : _sink_vio(sink), _buffer(new_MIOBuffer(BUFFER_SIZE_INDEX_4K)) +{ + this->_reader = this->_buffer->alloc_reader(); +} Http3StreamDataVIOAdaptor::~Http3StreamDataVIOAdaptor() { + this->_buffer->dealloc_reader(this->_reader); free_MIOBuffer(this->_buffer); } @@ -53,17 +57,17 @@ Http3StreamDataVIOAdaptor::handle_frame(std::shared_ptr frame, void Http3StreamDataVIOAdaptor::finalize() { - SCOPED_MUTEX_LOCK(lock, this->_sink_vio->mutex, this_ethread()); - MIOBuffer *writer = this->_sink_vio->get_writer(); - IOBufferReader *reader = this->_buffer->alloc_reader(); - IOBufferBlock *block; - while (reader->read_avail() > 0 && (block = reader->get_current_block()) != nullptr) { - writer->append_block(block); - reader->consume(block->size()); + if (this->_finalized) { + return; } - this->_buffer->dealloc_reader(reader); - this->_sink_vio->nbytes = this->_total_data_length; + SCOPED_MUTEX_LOCK(lock, this->_sink_vio->mutex, this_ethread()); + MIOBuffer *writer = this->_sink_vio->get_writer(); + int64_t delivered = writer->write(this->_reader, this->_reader->read_avail()); + this->_reader->consume(delivered); + this->_sink_vio->ndone += delivered; + this->_sink_vio->nbytes = this->_sink_vio->ndone; + this->_finalized = true; } bool diff --git a/src/proxy/http3/Http3Transaction.cc b/src/proxy/http3/Http3Transaction.cc index 8cdb7c3aead..9c437aea595 100644 --- a/src/proxy/http3/Http3Transaction.cc +++ b/src/proxy/http3/Http3Transaction.cc @@ -31,6 +31,7 @@ #include "proxy/http3/Http3HeaderVIOAdaptor.h" #include "proxy/http3/Http3HeaderFramer.h" #include "proxy/http3/Http3DataFramer.h" +#include "proxy/http3/Http3ProtocolEnforcer.h" #include "proxy/http/HttpSM.h" #define NetVC2QUICCon(netvc) netvc->get_service()->get_quic_connection() @@ -63,7 +64,8 @@ DbgCtl dbg_ctl_v_http3_trans{"v_http3_trans"}; // // HQTransaction // -HQTransaction::HQTransaction(HQSession *session, QUICStreamVCAdapter::IOInfo &info) : super(session), _info(info) +HQTransaction::HQTransaction(HQSession *session, QUICStreamVCAdapter::IOInfo &info) + : super(session), _info(info), _stream_id(info.adapter.stream().id()) { this->mutex = new_ProxyMutex(); this->_thread = this_ethread(); @@ -81,12 +83,16 @@ HQTransaction::~HQTransaction() this->_unschedule_write_complete_event(); static_cast(this->_proxy_ssn)->remove_transaction(this); + + if (this->_stream_cleanup) { + this->_stream_cleanup(); + } } void HQTransaction::set_active_timeout(ink_hrtime timeout_in) { - if (this->_proxy_ssn) { + if (!this->_closed && this->_proxy_ssn) { this->_proxy_ssn->set_active_timeout(timeout_in); } } @@ -94,7 +100,7 @@ HQTransaction::set_active_timeout(ink_hrtime timeout_in) void HQTransaction::set_inactivity_timeout(ink_hrtime timeout_in) { - if (this->_proxy_ssn) { + if (!this->_closed && this->_proxy_ssn) { this->_proxy_ssn->set_inactivity_timeout(timeout_in); } } @@ -102,7 +108,7 @@ HQTransaction::set_inactivity_timeout(ink_hrtime timeout_in) void HQTransaction::cancel_inactivity_timeout() { - if (this->_proxy_ssn) { + if (!this->_closed && this->_proxy_ssn) { this->_proxy_ssn->cancel_inactivity_timeout(); } } @@ -132,7 +138,7 @@ HQTransaction::do_io_read(Continuation *c, int64_t nbytes, MIOBuffer *buf) if (buf) { this->_process_read_vio(); - this->_schedule_read_ready_event(); + this->_schedule_read_event(); } return &this->_read_vio; @@ -166,6 +172,8 @@ HQTransaction::do_io_write(Continuation *c, int64_t nbytes, IOBufferReader *buf, void HQTransaction::do_io_close(int /* lerrno ATS_UNUSED */) { + this->_closed = true; + this->_read_vio.buffer.clear(); this->_read_vio.nbytes = 0; this->_read_vio.op = VIO::NONE; @@ -186,6 +194,10 @@ HQTransaction::do_io_shutdown(ShutdownHowTo_t /* howto ATS_UNUSED */) void HQTransaction::reenable(VIO *vio) { + if (this->_closed || this->_stream_closed) { + return; + } + if (vio->op == VIO::READ) { int64_t len = this->_process_read_vio(); this->_info.read_vio->reenable(); @@ -209,13 +221,28 @@ HQTransaction::transaction_done() // TODO: start closing transaction super::transaction_done(); this->_transaction_done = true; + this->_delete_if_possible(); return; } int HQTransaction::get_transaction_id() const { - return this->_info.adapter.stream().id(); + return this->_stream_id; +} + +void +HQTransaction::stream_closed() +{ + this->_stream_closed = true; + this->do_io_close(); + this->_delete_if_possible(); +} + +void +HQTransaction::set_stream_cleanup(std::function cleanup) +{ + this->_stream_cleanup = cleanup; } void @@ -276,6 +303,20 @@ HQTransaction::_schedule_read_complete_event() this->_read_complete_event = this->_thread->schedule_imm(this, VC_EVENT_READ_COMPLETE, &this->_read_vio); } +void +HQTransaction::_schedule_read_event() +{ + if (this->_read_vio.nbytes == 0) { + return; + } + + if (this->_info.read_vio->nbytes == INT64_MAX) { + this->_schedule_read_ready_event(); + } else { + this->_schedule_read_complete_event(); + } +} + void HQTransaction::_unschedule_read_complete_event() { @@ -353,17 +394,23 @@ HQTransaction::_close_write_complete_event(Event *e) } void -HQTransaction::_signal_event(int event, Event * /* edata ATS_UNUSED */) +HQTransaction::_signal_event(int event, Event *) { - // HttpSM::main_handler expects a VIO* as the event data for VC events so it - // can locate the vc_table entry. - if (this->_write_vio.cont) { - SCOPED_MUTEX_LOCK(lock, this->_write_vio.mutex, this_ethread()); - this->_write_vio.cont->handleEvent(event, &this->_write_vio); + if (this->_closed || this->_stream_closed) { + return; } - if (this->_read_vio.cont && this->_read_vio.cont != this->_write_vio.cont) { + + // HttpSM::main_handler expects a VIO* as the event data for VC events so it + // can locate the vc_table entry. Prefer the read side because H3 creates a + // zero-byte write VIO before HttpSM installs a client write handler. + if (this->_read_vio.cont && this->_read_vio.op != VIO::NONE) { SCOPED_MUTEX_LOCK(lock, this->_read_vio.mutex, this_ethread()); this->_read_vio.cont->handleEvent(event, &this->_read_vio); + return; + } + if (this->_write_vio.cont && this->_write_vio.op != VIO::NONE && this->_write_vio.nbytes > 0) { + SCOPED_MUTEX_LOCK(lock, this->_write_vio.mutex, this_ethread()); + this->_write_vio.cont->handleEvent(event, &this->_write_vio); } } @@ -373,10 +420,10 @@ HQTransaction::_signal_event(int event, Event * /* edata ATS_UNUSED */) void HQTransaction::_signal_read_event() { - if (this->_read_vio.cont == nullptr || this->_read_vio.op == VIO::NONE) { + if (this->_closed || this->_stream_closed || this->_read_vio.cont == nullptr || this->_read_vio.op == VIO::NONE) { return; } - int event = this->_read_vio.nbytes == INT64_MAX ? VC_EVENT_READ_READY : VC_EVENT_READ_COMPLETE; + int event = this->_read_vio.nbytes == INT64_MAX || this->_read_vio.ntodo() > 0 ? VC_EVENT_READ_READY : VC_EVENT_READ_COMPLETE; SCOPED_MUTEX_LOCK(lock, this->_read_vio.mutex, this_ethread()); this->_read_vio.cont->handleEvent(event, &this->_read_vio); @@ -390,9 +437,13 @@ HQTransaction::_signal_read_event() void HQTransaction::_signal_write_event() { - if (this->_write_vio.cont == nullptr || this->_write_vio.op == VIO::NONE) { + if (this->_closed || this->_stream_closed || this->_write_vio.cont == nullptr || this->_write_vio.op == VIO::NONE) { + return; + } + if (this->_write_vio.ntodo() == 0 && !this->_is_write_buffer_flushed()) { return; } + int event = this->_write_vio.ntodo() ? VC_EVENT_WRITE_READY : VC_EVENT_WRITE_COMPLETE; SCOPED_MUTEX_LOCK(lock, this->_write_vio.mutex, this_ethread()); @@ -401,6 +452,22 @@ HQTransaction::_signal_write_event() Http3TransVDebug("%s (%d)", get_vc_event_name(event), event); } +bool +HQTransaction::_is_write_buffer_flushed() +{ + if (this->_closed || this->_stream_closed) { + return true; + } + + if (this->_info.write_vio->op == VIO::NONE) { + return true; + } + + SCOPED_MUTEX_LOCK(lock, this->_info.write_vio->mutex, this_ethread()); + + return this->_info.write_vio->ntodo() == 0; +} + /** * Deletes this transaction itself. * This must be called only at the end of event handlers to avoid touching itself after deletion. @@ -408,7 +475,11 @@ HQTransaction::_signal_write_event() void HQTransaction::_delete_if_possible() { - if (this->_transaction_done) { + if (this->_event_handler_active) { + return; + } + + if (this->_transaction_done && this->_is_write_buffer_flushed() && (this->_stream_closed || !this->_info.adapter.is_readable())) { delete this; } } @@ -432,10 +503,12 @@ Http3Transaction::Http3Transaction(Http3Session *session, QUICStreamVCAdapter::I } else { http_type = HTTPType::REQUEST; } - this->_header_handler = new Http3HeaderVIOAdaptor(&this->_read_vio, http_type, session->remote_qpack(), stream_id, this); - this->_data_handler = new Http3StreamDataVIOAdaptor(&this->_read_vio); + this->_protocol_enforcer = new Http3ProtocolEnforcer(); + this->_header_handler = new Http3HeaderVIOAdaptor(&this->_read_vio, http_type, session->remote_qpack(), stream_id, this); + this->_data_handler = new Http3StreamDataVIOAdaptor(&this->_read_vio); this->_frame_dispatcher.add_handler(session->get_received_frame_counter()); + this->_frame_dispatcher.add_handler(this->_protocol_enforcer); this->_frame_dispatcher.add_handler(this->_header_handler); this->_frame_dispatcher.add_handler(this->_data_handler); @@ -453,6 +526,8 @@ Http3Transaction::~Http3Transaction() this->_header_framer = nullptr; delete this->_data_framer; this->_data_framer = nullptr; + delete this->_protocol_enforcer; + this->_protocol_enforcer = nullptr; delete this->_header_handler; this->_header_handler = nullptr; delete this->_data_handler; @@ -465,6 +540,7 @@ Http3Transaction::state_stream_open(int event, Event *edata) // TODO: should check recursive call? ink_release_assert(this->_thread == this_ethread()); SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread()); + this->_event_handler_active = true; switch (event) { case VC_EVENT_READ_READY: @@ -474,22 +550,29 @@ Http3Transaction::state_stream_open(int event, Event *edata) if (this->_process_read_vio() > 0) { this->_signal_read_event(); } - this->_info.read_vio->reenable(); + if (!this->_closed) { + this->_info.read_vio->reenable(); + } break; - case VC_EVENT_READ_COMPLETE: + case VC_EVENT_READ_COMPLETE: { Http3TransVDebug("%s (%d)", get_vc_event_name(event), event); this->_close_read_complete_event(edata); - this->_process_read_vio(); + int64_t nread = this->_process_read_vio(); if (!this->_header_handler->is_complete()) { - // Delay processing READ_COMPLETE - this->_schedule_read_complete_event(); + if (nread > 0) { + // Delay processing READ_COMPLETE until the header block can be fully decoded. + this->_schedule_read_complete_event(); + } break; } this->_data_handler->finalize(); // always signal regardless of progress this->_signal_read_event(); - this->_info.read_vio->reenable(); + if (!this->_closed) { + this->_info.read_vio->reenable(); + } break; + } case VC_EVENT_WRITE_READY: this->_close_write_ready_event(edata); Http3TransVDebug("%s (%d)", get_vc_event_name(event), event); @@ -497,7 +580,9 @@ Http3Transaction::state_stream_open(int event, Event *edata) if (this->_process_write_vio() > 0) { this->_signal_write_event(); } - this->_info.write_vio->reenable(); + if (!this->_closed) { + this->_info.write_vio->reenable(); + } break; case VC_EVENT_WRITE_COMPLETE: this->_close_write_complete_event(edata); @@ -505,7 +590,9 @@ Http3Transaction::state_stream_open(int event, Event *edata) this->_process_write_vio(); // always signal regardless of progress this->_signal_write_event(); - this->_info.write_vio->reenable(); + if (!this->_closed) { + this->_info.write_vio->reenable(); + } break; case VC_EVENT_EOS: case VC_EVENT_ERROR: @@ -513,12 +600,14 @@ Http3Transaction::state_stream_open(int event, Event *edata) case VC_EVENT_ACTIVE_TIMEOUT: { Http3TransVDebug("%s (%d)", get_vc_event_name(event), event); this->_signal_event(event, edata); + this->do_io_close(); break; } default: Http3TransDebug("Unknown event %d", event); } + this->_event_handler_active = false; this->_delete_if_possible(); return EVENT_DONE; } @@ -527,6 +616,7 @@ int Http3Transaction::state_stream_closed(int event, Event *data) { Http3TransVDebug("%s (%d)", get_vc_event_name(event), event); + this->_event_handler_active = true; switch (event) { case VC_EVENT_READ_READY: @@ -553,6 +643,7 @@ Http3Transaction::state_stream_closed(int event, Event *data) Http3TransDebug("Unknown event %d", event); } + this->_event_handler_active = false; this->_delete_if_possible(); return EVENT_DONE; } @@ -564,6 +655,12 @@ Http3Transaction::do_io_close(int lerrno) super::do_io_close(lerrno); } +void +Http3Transaction::on_header_decode_complete() +{ + this->_schedule_read_event(); +} + bool Http3Transaction::is_response_header_sent() const { @@ -576,9 +673,26 @@ Http3Transaction::is_response_body_sent() const return this->_data_framer->is_done(); } +void +Http3Transaction::_handle_error(const Http3Error &error) +{ + if (error.cls == Http3ErrorClass::CONNECTION) { + this->do_io_close(); + this->_transaction_done = true; + this->_stream_closed = true; + NetVC2QUICCon(this->_proxy_ssn->get_netvc()) + ->close_quic_connection( + std::make_unique(QUICErrorClass::APPLICATION, static_cast(error.code))); + } +} + int64_t Http3Transaction::_process_read_vio() { + if (this->_stream_closed) { + return 0; + } + if (this->_info.read_vio->cont == nullptr || this->_info.read_vio->op == VIO::NONE) { return 0; } @@ -590,7 +704,8 @@ Http3Transaction::_process_read_vio() auto error = this->_frame_dispatcher.on_read_ready(this->_info.adapter.stream().id(), Http3StreamType::UNKNOWN, *this->_info.read_vio->get_reader(), nread); if (error && error->cls != Http3ErrorClass::UNDEFINED) { - Http3TransDebug("Error occurred while processing read vio: %hu, %s", error->get_code(), error->msg); + Http3TransDebug("Error occurred while processing read vio: %hu", error->get_code()); + this->_handle_error(*error); return 0; } this->_info.read_vio->ndone += nread; @@ -600,6 +715,10 @@ Http3Transaction::_process_read_vio() int64_t Http3Transaction::_process_write_vio() { + if (this->_stream_closed) { + return 0; + } + if (this->_info.write_vio->cont == nullptr || this->_info.write_vio->op == VIO::NONE) { return 0; } @@ -612,7 +731,7 @@ Http3Transaction::_process_write_vio() auto error = this->_frame_collector.on_write_ready(this->_info.adapter.stream().id(), *this->_info.write_vio->get_writer(), nwritten, all_done); if (error && error->cls != Http3ErrorClass::UNDEFINED) { - Http3TransDebug("Error occured while processing write vio: %hu, %s", error->get_code(), error->msg); + Http3TransDebug("Error occurred while processing write vio: %hu", error->get_code()); return 0; } this->_sent_bytes += nwritten; @@ -636,6 +755,10 @@ Http3Transaction::has_request_body(int64_t content_length, bool /* is_chunked_se return true; } + if (this->_stream_closed) { + return false; + } + // No body if stream is already closed and DATA frame is not received yet if (this->_info.adapter.stream().has_no_more_data()) { return false; @@ -669,6 +792,7 @@ Http09Transaction::state_stream_open(int event, Event *edata) ink_release_assert(this->_thread == this_ethread()); SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread()); + this->_event_handler_active = true; switch (event) { case VC_EVENT_READ_READY: @@ -706,12 +830,15 @@ Http09Transaction::state_stream_open(int event, Event *edata) case VC_EVENT_INACTIVITY_TIMEOUT: case VC_EVENT_ACTIVE_TIMEOUT: { Http3TransDebug("%d", event); + this->_signal_event(event, edata); + this->do_io_close(); break; } default: Http3TransDebug("Unknown event %d", event); } + this->_event_handler_active = false; this->_delete_if_possible(); return EVENT_DONE; } @@ -727,6 +854,7 @@ int Http09Transaction::state_stream_closed(int event, Event *data) { Http3TransVDebug("%s (%d)", get_vc_event_name(event), event); + this->_event_handler_active = true; switch (event) { case VC_EVENT_READ_READY: @@ -752,6 +880,7 @@ Http09Transaction::state_stream_closed(int event, Event *data) Http3TransDebug("Unknown event %d", event); } + this->_event_handler_active = false; this->_delete_if_possible(); return EVENT_DONE; } @@ -760,6 +889,10 @@ Http09Transaction::state_stream_closed(int event, Event *data) int64_t Http09Transaction::_process_read_vio() { + if (this->_stream_closed) { + return 0; + } + if (this->_read_vio.cont == nullptr || this->_read_vio.op == VIO::NONE) { return 0; } @@ -840,6 +973,10 @@ static constexpr char http_1_1_version[] = "HTTP/1.1"; int64_t Http09Transaction::_process_write_vio() { + if (this->_stream_closed) { + return 0; + } + if (this->_write_vio.cont == nullptr || this->_write_vio.op == VIO::NONE) { return 0; } diff --git a/src/proxy/http3/QPACK.cc b/src/proxy/http3/QPACK.cc index dfdd2d278b3..bcfa4c70b8d 100644 --- a/src/proxy/http3/QPACK.cc +++ b/src/proxy/http3/QPACK.cc @@ -69,7 +69,7 @@ const QPACK::Header QPACK::StaticTable::STATIC_HEADER_FIELDS[] = { {":status", "503" }, {"accept", "*/*" }, {"accept", "application/dns-message" }, - {"accept-encoding", "gzip, deflate, br, zstd" }, + {"accept-encoding", "gzip, deflate, br" }, {"accept-ranges", "bytes" }, {"access-control-allow-headers", "cache-control" }, {"access-control-allow-headers", "content-type" }, @@ -80,7 +80,6 @@ const QPACK::Header QPACK::StaticTable::STATIC_HEADER_FIELDS[] = { {"cache-control", "no-cache" }, {"cache-control", "no-store" }, {"cache-control", "public, max-age=31536000" }, - {"content-encoding", "zstd" }, {"content-encoding", "br" }, {"content-encoding", "gzip" }, {"content-type", "application/dns-message" }, diff --git a/src/proxy/http3/test/test_Http3FrameDispatcher.cc b/src/proxy/http3/test/test_Http3FrameDispatcher.cc index 0c7a65b749c..37560e9a157 100644 --- a/src/proxy/http3/test/test_Http3FrameDispatcher.cc +++ b/src/proxy/http3/test/test_Http3FrameDispatcher.cc @@ -231,7 +231,7 @@ TEST_CASE("control stream tests", "[http3]") CHECK(nread == sizeof(input)); } - SECTION("RESERVED frame is not allowed on control stream") + SECTION("HTTP/2 reserved frame is not allowed on control stream") { uint8_t input[] = {0x04, // Type 0x08, // Length @@ -258,6 +258,32 @@ TEST_CASE("control stream tests", "[http3]") CHECK(nread == sizeof(input)); } + SECTION("GREASE reserved frame is ignored on control stream") + { + uint8_t input[] = {0x04, // Type + 0x08, // Length + 0x06, // Identifier + 0x44, 0x00, // Value + 0x09, // Identifier + 0x0f, // Value + 0x4a, 0x0a, // Identifier + 0x00, // Value + 0x21, // Type: reserved by the 0x21 + 0x1f * N pattern + 0x04, // Length + 0x11, 0x22, 0x33, 0x44}; + + buf->write(input, sizeof(input)); + + // Initial state + CHECK(handler.total_frame_received == 0); + CHECK(nread == 0); + + error = http3FrameDispatcher.on_read_ready(0, Http3StreamType::CONTROL, *reader, nread); + CHECK(!error); + CHECK(handler.total_frame_received == 1); + CHECK(nread == sizeof(input)); + } + SECTION("padding should not be interpreted as a DATA frame", "[http3]") { uint8_t input[] = { @@ -311,7 +337,7 @@ TEST_CASE("ignore unknown frames", "[http3]") } } -TEST_CASE("Reserved frame type not allowed", "[http3]") +TEST_CASE("HTTP/2 reserved frame type not allowed", "[http3]") { SECTION("Reject reserved frame type in non control stream") { diff --git a/src/proxy/http3/test/test_QPACK.cc b/src/proxy/http3/test/test_QPACK.cc index 438571d1149..338edeea583 100644 --- a/src/proxy/http3/test/test_QPACK.cc +++ b/src/proxy/http3/test/test_QPACK.cc @@ -85,7 +85,9 @@ class TestQUICStream : public QUICStream auto ibb = this->_adapter->read(buf_len); IOBufferReader reader; reader.block = ibb; - return reader.read(buf, buf_len); + auto nread = reader.read(buf, buf_len); + this->_adapter->consume(nread); + return nread; } }; diff --git a/tests/gold_tests/autest-site/ats_replay.test.ext b/tests/gold_tests/autest-site/ats_replay.test.ext index d6379025b20..4557cf2b082 100644 --- a/tests/gold_tests/autest-site/ats_replay.test.ext +++ b/tests/gold_tests/autest-site/ats_replay.test.ext @@ -35,6 +35,8 @@ def configure_ats(obj: 'TestRun', server: 'Process', ats_config: dict, dns: Opti name = ats_config.get('name', 'ts') process_config = ats_config.get('process_config', {}) ts = obj.MakeATSProcess(name, **process_config) + if 'startup_timeout' in ats_config: + ts.StartupTimeout = ats_config['startup_timeout'] # Configure records_config if specified. records_config = ats_config.get('records_config', {}) @@ -273,6 +275,7 @@ def ATSReplayTest(obj, replay_file: str): ats_config = autest_config['ats'] process_config = ats_config.get('process_config', {}) enable_tls = process_config.get('enable_tls', False) + enable_quic = process_config.get('enable_quic', False) metric_checks = ats_config.get('metric_checks', []) log_validation = ats_config.get('log_validation', None) @@ -286,8 +289,9 @@ def ATSReplayTest(obj, replay_file: str): name = client_config.get('name', 'client') process_config = client_config.get('process_config', {}) https_ports = [ts.Variables.ssl_port] if enable_tls else None + http3_ports = [ts.Variables.ssl_port] if enable_quic else None client = tr.AddVerifierClientProcess( - name, replay_file, http_ports=[ts.Variables.port], https_ports=https_ports, **process_config) + name, replay_file, http_ports=[ts.Variables.port], https_ports=https_ports, http3_ports=http3_ports, **process_config) # Set expected return code if specified. A list of codes is wrapped in # Any() so that any of the listed values is accepted. diff --git a/tests/gold_tests/autest-site/conditions.test.ext b/tests/gold_tests/autest-site/conditions.test.ext index 41c5a6cacdf..090075c4ff8 100644 --- a/tests/gold_tests/autest-site/conditions.test.ext +++ b/tests/gold_tests/autest-site/conditions.test.ext @@ -33,6 +33,12 @@ OPENSSL_TLS_FLAGS = { } +def _version_tuple(value, width=3): + parts = [int(part) for part in re.findall(r'\d+', value)[:width]] + parts.extend([0] * (width - len(parts))) + return tuple(parts) + + def _terminate_process(process): if process.poll() is not None: return @@ -130,10 +136,27 @@ def HasOpenSSLVersion(self, version): output = subprocess.check_output(os.path.join(self.Variables.BINDIR, "traffic_layout") + " info --versions --json", shell=True) json_data = output.decode('utf-8') openssl_str = json.loads(json_data)['openssl_str'] - exe_ver = re.search(r'\d\.\d\.\d', openssl_str).group(0) - if exe_ver == '': + match = re.search(r'\d+(?:\.\d+)+', openssl_str) + if match is None: raise ValueError("Error determining version of OpenSSL library needed by traffic_server executable") - return self.Condition(lambda: exe_ver >= version, "OpenSSL library version is " + exe_ver + ", must be at least " + version) + exe_ver = match.group(0) + return self.Condition( + lambda: _version_tuple(exe_ver) >= _version_tuple(version), + "OpenSSL library version is " + exe_ver + ", must be at least " + version) + + +def HasOpenSSLQuicClient(self): + """Check whether the openssl CLI supports s_client -quic.""" + + def check_openssl_quic_client(): + try: + result = subprocess.run(["openssl", "s_client", "-help"], capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError): + return False + + return "-quic" in result.stdout or "-quic" in result.stderr + + return self.Condition(check_openssl_quic_client, "OpenSSL CLI must support s_client -quic") def IsBoringSSL(self): @@ -217,6 +240,26 @@ def HasProxyVerifierVersion(self, version): return self.EnsureVersion([verifier_path, "--version"], min_version=version) +def HasGoVersion(self, version): + """Check whether the go command is available at the requested version.""" + + def check_go_version(): + try: + output = subprocess.check_output(["go", "version"], stderr=subprocess.STDOUT, text=True) + except (OSError, subprocess.SubprocessError): + return False + + match = re.search(r'\bgo(\d+\.\d+(?:\.\d+)?)\b', output) + if match is None: + return False + + found = _version_tuple(match.group(1)) + required = _version_tuple(version) + return found >= required + + return self.Condition(check_go_version, "Go must be installed and at least version " + version) + + def HasCurlFeature(self, feature): def default(output): @@ -325,7 +368,9 @@ def CurlUsingUnixDomainSocket(self): ExtendCondition(HasOpenSSLVersion) +ExtendCondition(HasOpenSSLQuicClient) ExtendCondition(HasProxyVerifierVersion) +ExtendCondition(HasGoVersion) ExtendCondition(IsBoringSSL) ExtendCondition(IsOpenSSL) ExtendCondition(HasLegacyTLSSupport) diff --git a/tests/gold_tests/early_hints/early_hints.test.py b/tests/gold_tests/early_hints/early_hints.test.py index 0f646624692..ddcc488be88 100644 --- a/tests/gold_tests/early_hints/early_hints.test.py +++ b/tests/gold_tests/early_hints/early_hints.test.py @@ -28,6 +28,7 @@ class Protocol(Enum): HTTP = auto() HTTPS = auto() HTTP2 = auto() + HTTP3 = auto() @classmethod def to_string(cls, protocol): @@ -37,6 +38,8 @@ def to_string(cls, protocol): return 'HTTPS' elif protocol == cls.HTTP2: return 'HTTP2' + elif protocol == cls.HTTP3: + return 'HTTP3' else: return None @@ -87,7 +90,7 @@ def _configure_ts(self, tr: 'TestRun'): :param tr: The TestRun for the traffic server. ''' - ts = Test.MakeATSProcess(f'ts_{self._protocol_str}', enable_tls=True) + ts = Test.MakeATSProcess(f'ts_{self._protocol_str}', enable_tls=True, enable_quic=self._protocol == Protocol.HTTP3) self._ts = ts ts.Disk.remap_config.AddLine(f'map / http://backend.server.com:{self._server.Variables.http_port}') ts.addDefaultSSLFiles() @@ -137,6 +140,10 @@ def _configure_client(self, tr: 'TestRun'): protocol_arg = '-k --http2' scheme = 'https' ts_port = self._ts.Variables.ssl_port + elif self._protocol == Protocol.HTTP3: + protocol_arg = '-k --http3-only' + scheme = 'https' + ts_port = self._ts.Variables.ssl_port tr.MakeCurlCommand( f'-v {protocol_arg} ' f'--resolve "server.com:{ts_port}:127.0.0.1" ' @@ -164,3 +171,5 @@ def _configure_client(self, tr: 'TestRun'): if not Condition.CurlUsingUnixDomainSocket(): TestEarlyHints(Protocol.HTTPS) TestEarlyHints(Protocol.HTTP2) + if Condition.HasATSFeature('TS_USE_QUIC') and Condition.HasCurlFeature('http3') and Condition.HasCurlOption('--http3-only'): + TestEarlyHints(Protocol.HTTP3) diff --git a/tests/gold_tests/h3/go_h3_client/go.mod b/tests/gold_tests/h3/go_h3_client/go.mod new file mode 100644 index 00000000000..9f8dd05a0be --- /dev/null +++ b/tests/gold_tests/h3/go_h3_client/go.mod @@ -0,0 +1,13 @@ +module trafficserver.apache.org/h3-go-client + +go 1.24 + +require github.com/quic-go/quic-go v0.59.1 + +require ( + github.com/quic-go/qpack v0.6.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/tests/gold_tests/h3/go_h3_client/go.sum b/tests/gold_tests/h3/go_h3_client/go.sum new file mode 100644 index 00000000000..314cb107874 --- /dev/null +++ b/tests/gold_tests/h3/go_h3_client/go.sum @@ -0,0 +1,38 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/gold_tests/h3/go_h3_client/main.go b/tests/gold_tests/h3/go_h3_client/main.go new file mode 100644 index 00000000000..8e73127a333 --- /dev/null +++ b/tests/gold_tests/h3/go_h3_client/main.go @@ -0,0 +1,300 @@ +// 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. + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "flag" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +const ( + largeBodySize = 300000 + largeBodySuffix = "000927b " + reusedHeaderValue = "stable-qpack-value" +) + +type requestCase struct { + name string + method string + path string + requestSize int + responseSize int + status int +} + +func generatedBody(size int) []byte { + var body bytes.Buffer + for i := 0; body.Len() < size; i++ { + fmt.Fprintf(&body, "%07x ", i) + } + return body.Bytes()[:size] +} + +func newTLSConfig(serverName string) *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{http3.NextProtoH3}, + ServerName: serverName, + } +} + +func newQUICConfig() *quic.Config { + return &quic.Config{ + MaxIdleTimeout: 10 * time.Second, + } +} + +func newClient(serverName string) (*http.Client, *http3.Transport) { + transport := &http3.Transport{ + TLSClientConfig: newTLSConfig(serverName), + QUICConfig: newQUICConfig(), + DisableCompression: true, + } + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + return client, transport +} + +func newRequest(ctx context.Context, baseURL string, authority string, tc requestCase) (*http.Request, error) { + var body io.Reader + if tc.requestSize > 0 { + body = bytes.NewReader(generatedBody(tc.requestSize)) + } + + req, err := http.NewRequestWithContext(ctx, tc.method, baseURL+tc.path, body) + if err != nil { + return nil, err + } + + req.Host = authority + req.Header.Set("User-Agent", "ats-h3-quic-go-autest") + req.Header.Set("X-H3-Go-Client", "quic-go") + req.Header.Set("X-H3-Reused-Header", reusedHeaderValue) + req.Header.Set("X-H3-Test-Case", tc.name) + req.Header.Set("uuid", tc.name) + if tc.requestSize > 0 { + req.Header.Set("Content-Type", "application/octet-stream") + } + + return req, nil +} + +func verifyResponse(tc requestCase, resp *http.Response) error { + defer resp.Body.Close() + + if resp.ProtoMajor != 3 { + return fmt.Errorf("%s: expected HTTP/3, got %s", tc.name, resp.Proto) + } + if resp.StatusCode != tc.status { + return fmt.Errorf("%s: expected status %d, got %d", tc.name, tc.status, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%s: read response body: %w", tc.name, err) + } + + if tc.method == http.MethodHead || tc.status == http.StatusNoContent { + if len(body) != 0 { + return fmt.Errorf("%s: expected no response body, got %d bytes", tc.name, len(body)) + } + return nil + } + + expected := generatedBody(tc.responseSize) + if !bytes.Equal(body, expected) { + return fmt.Errorf("%s: response body mismatch: got %d bytes, expected %d", tc.name, len(body), len(expected)) + } + if tc.responseSize == largeBodySize && !bytes.HasSuffix(body, []byte(largeBodySuffix)) { + return fmt.Errorf("%s: large response body does not end with %q", tc.name, largeBodySuffix) + } + + return nil +} + +func doRequest( + ctx context.Context, + roundTrip func(*http.Request) (*http.Response, error), + baseURL string, + authority string, + tc requestCase, +) error { + req, err := newRequest(ctx, baseURL, authority, tc) + if err != nil { + return err + } + + resp, err := roundTrip(req) + if err != nil { + return fmt.Errorf("%s: request failed: %w", tc.name, err) + } + + if err := verifyResponse(tc, resp); err != nil { + return err + } + + fmt.Printf("ok %s\n", tc.name) + return nil +} + +func runSequential(ctx context.Context, baseURL string, authority string, serverName string, cases []requestCase) error { + client, transport := newClient(serverName) + defer transport.Close() + + for _, tc := range cases { + if err := doRequest(ctx, client.Do, baseURL, authority, tc); err != nil { + return err + } + } + + return nil +} + +func runConcurrent(ctx context.Context, addr string, baseURL string, authority string, serverName string, cases []requestCase) error { + transport := &http3.Transport{} + defer transport.Close() + + conn, err := quic.DialAddr(ctx, addr, newTLSConfig(serverName), newQUICConfig()) + if err != nil { + return fmt.Errorf("dial concurrent HTTP/3 connection: %w", err) + } + clientConn := transport.NewClientConn(conn) + defer clientConn.CloseWithError(0, "done") + + var wg sync.WaitGroup + errs := make(chan error, len(cases)) + for _, tc := range cases { + tc := tc + wg.Add(1) + go func() { + defer wg.Done() + errs <- doRequest(ctx, clientConn.RoundTrip, baseURL, authority, tc) + }() + } + + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +func main() { + addr := flag.String("addr", "", "ATS HTTP/3 address in host:port form") + authority := flag.String("authority", "", "HTTP/3 request authority") + serverName := flag.String("server-name", "", "TLS SNI server name") + flag.Parse() + + if *addr == "" || *authority == "" || *serverName == "" { + flag.Usage() + os.Exit(2) + } + + baseURL := "https://" + *addr + ctx := context.Background() + sequentialCases := []requestCase{ + {name: "go-get-empty", method: http.MethodGet, path: "/go-get-empty", status: http.StatusOK}, + {name: "go-get-small", method: http.MethodGet, path: "/go-get-small", responseSize: 100, status: http.StatusOK}, + {name: "go-head-no-body", method: http.MethodHead, path: "/go-head-no-body", responseSize: 100, status: http.StatusOK}, + {name: "go-204-no-body", method: http.MethodGet, path: "/go-204-no-body", status: http.StatusNoContent}, + { + name: "go-post-small", + method: http.MethodPost, + path: "/go-post-small", + requestSize: 100, + responseSize: 100, + status: http.StatusOK, + }, + { + name: "go-put-small", + method: http.MethodPut, + path: "/go-put-small", + requestSize: 100, + responseSize: 100, + status: http.StatusOK, + }, + {name: "go-delete-empty", method: http.MethodDelete, path: "/go-delete-empty", status: http.StatusNoContent}, + {name: "go-options-small", method: http.MethodOptions, path: "/go-options-small", responseSize: 100, status: http.StatusOK}, + } + concurrentCases := []requestCase{ + { + name: "go-get-concurrent-large", + method: http.MethodGet, + path: "/go-get-concurrent-large", + responseSize: largeBodySize, + status: http.StatusOK, + }, + { + name: "go-get-concurrent-small", + method: http.MethodGet, + path: "/go-get-concurrent-small", + responseSize: 100, + status: http.StatusOK, + }, + } + largeCases := []requestCase{ + {name: "go-get-large", method: http.MethodGet, path: "/go-get-large", responseSize: largeBodySize, status: http.StatusOK}, + { + name: "go-post-large", + method: http.MethodPost, + path: "/go-post-large", + requestSize: largeBodySize, + responseSize: largeBodySize, + status: http.StatusOK, + }, + { + name: "go-put-large", + method: http.MethodPut, + path: "/go-put-large", + requestSize: largeBodySize, + responseSize: largeBodySize, + status: http.StatusOK, + }, + } + + if err := runSequential(ctx, baseURL, *authority, *serverName, sequentialCases); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := runConcurrent(ctx, *addr, baseURL, *authority, *serverName, concurrentCases); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := runSequential(ctx, baseURL, *authority, *serverName, largeCases); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Println("completed 13 HTTP/3 requests") +} diff --git a/tests/gold_tests/h3/h3_active_timeout.test.py b/tests/gold_tests/h3/h3_active_timeout.test.py new file mode 100644 index 00000000000..9a159fb49c6 --- /dev/null +++ b/tests/gold_tests/h3/h3_active_timeout.test.py @@ -0,0 +1,26 @@ +''' +Verify HTTP/3 transaction cleanup on active timeout. +''' +# 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. + +Test.Summary = ''' +Verify HTTP/3 transactions are removed cleanly after transaction active timeout. +''' + +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) + +Test.ATSReplayTest(replay_file="replays/h3_active_timeout.replay.yaml") diff --git a/tests/gold_tests/h3/h3_curl.test.py b/tests/gold_tests/h3/h3_curl.test.py new file mode 100644 index 00000000000..0fffd6b3d10 --- /dev/null +++ b/tests/gold_tests/h3/h3_curl.test.py @@ -0,0 +1,143 @@ +''' +Verify HTTP/3 client interop with curl. +''' +# 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 + +Test.Summary = ''' +This test is written specifically to verify that an HTTP/3 curl client can +complete a request through ATS. +''' + +Test.SkipUnless( + Condition.HasATSFeature('TS_USE_QUIC'), + Condition.HasCurlFeature('http3'), + Condition.HasCurlOption('--http3-only'), +) +Test.SkipIf(Condition.CurlUsingUnixDomainSocket()) + + +class TestHttp3Curl: + """Configure a test to verify HTTP/3 curl client interoperability.""" + + response_body = "0123456789" * 30000 + + def __init__(self, name: str): + """Initialize the test. + + :param name: The name of the test. + """ + self.name = name + self._body_path = os.path.join(Test.RunDirectory, "h3_curl_body.txt") + self._configure_server() + self._configure_traffic_server() + self._configure_client() + + def _configure_server(self): + """Configure the origin server.""" + server = Test.MakeOriginServer("server") + server.addResponse( + "sessionlog.json", { + "headers": "GET /h3-curl HTTP/1.1\r\nHost: localhost\r\n\r\n", + "timestamp": "1469733493.993", + "body": "" + }, { + "headers": f"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {len(self.response_body)}\r\n\r\n", + "timestamp": "1469733493.993", + "body": self.response_body + }) + + self._server = server + + def _configure_traffic_server(self): + """Configure Traffic Server.""" + ts = Test.MakeATSProcess("ts", enable_tls=True, enable_quic=True, enable_cache=False) + ts.StartupTimeout = 60 + ts.addDefaultSSLFiles() + ts.addSSLfile("../tls/ssl/signed-foo.pem") + ts.addSSLfile("../tls/ssl/signed-foo.key") + ts.Disk.ssl_multicert_yaml.AddLines( + ''' +ssl_multicert: + - ssl_cert_name: signed-foo.pem + ssl_key_name: signed-foo.key + - ssl_cert_name: server.pem + ssl_key_name: server.key + dest_ip: "*" +'''.split('\n')) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'quic|http3', + 'proxy.config.quic.server.stateless_retry_enabled': 0, + 'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir, + 'proxy.config.ssl.server.private_key.path': ts.Variables.SSLDir, + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.Port}') + ts.Disk.logging_yaml.AddLines( + ''' +logging: + formats: + - name: h3_access + format: 'c_alpn=% client_version=% c_ssl_version=% c_method=% c_url=%' + + logs: + - filename: h3_access + format: h3_access +'''.split("\n")) + + self._access_log = Test.Disk.File(os.path.join(ts.Variables.LOGDIR, 'h3_access.log'), exists=True) + self._access_log.Content = Testers.ContainsExpression( + r'c_alpn=h3 client_version=http/3 c_ssl_version=[^ ]+ c_method=GET c_url=https://foo.com:[0-9]+/h3-curl', + "ATS should log the curl request as HTTP/3") + + self._ts = ts + + def _check_curl_response(self, tr): + """Verify that curl received the response over HTTP/3.""" + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression( + f"size_download={len(self.response_body)}", "curl should receive the complete HTTP/3 response body") + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression("http_version=3", "curl should report HTTP/3") + + def _configure_client(self): + """Configure the curl client test runs.""" + tr = Test.AddTestRun(self.name) + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.MakeCurlCommand( + '--silent --show-error --fail --ipv4 --http3-only --insecure ' + f'--resolve "foo.com:{self._ts.Variables.ssl_port}:127.0.0.1" ' + f'--output "{self._body_path}" ' + '--write-out "\\nhttp_version=%{http_version}\\nsize_download=%{size_download}\\n" ' + f'https://foo.com:{self._ts.Variables.ssl_port}/h3-curl', + ts=self._ts) + self._check_curl_response(tr) + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + tr = Test.AddTestRun("Wait for HTTP/3 access log") + tr.Processes.Default.Command = ( + os.path.join(Test.Variables.AtsTestToolsDir, 'condwait') + ' 60 1 -f ' + + os.path.join(self._ts.Variables.LOGDIR, 'h3_access.log')) + tr.Processes.Default.ReturnCode = 0 + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + +TestHttp3Curl("curl forced HTTP/3 request") diff --git a/tests/gold_tests/h3/h3_flow_control.test.py b/tests/gold_tests/h3/h3_flow_control.test.py new file mode 100644 index 00000000000..85c4c5b5573 --- /dev/null +++ b/tests/gold_tests/h3/h3_flow_control.test.py @@ -0,0 +1,27 @@ +''' +Verify HTTP/3 traffic progresses with small QUIC flow-control windows. +''' +# 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. + +Test.Summary = ''' +Verify that large HTTP/3 request and response bodies complete when ATS +advertises small initial QUIC flow-control windows. +''' + +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) + +Test.ATSReplayTest(replay_file="replays/h3_flow_control.replay.yaml") diff --git a/tests/gold_tests/h3/h3_go_client.test.py b/tests/gold_tests/h3/h3_go_client.test.py new file mode 100644 index 00000000000..d99c1979e1e --- /dev/null +++ b/tests/gold_tests/h3/h3_go_client.test.py @@ -0,0 +1,130 @@ +''' +Verify HTTP/3 client interop with a quic-go client. +''' +# 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 + +Test.Summary = ''' +Verify that a quic-go HTTP/3 client can complete sequential and concurrent +transactions through ATS. +''' + +Test.SkipUnless( + Condition.HasATSFeature('TS_USE_QUIC'), + Condition.HasGoVersion('1.24'), +) + + +def add_default_ssl_multicert(ts): + """Configure the default server certificate.""" + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + + +class TestHttp3GoClient: + """Configure a test to verify HTTP/3 quic-go client interoperability.""" + + replay_file = "replays/h3_server_for_go_client.replay.yaml" + + def __init__(self, name: str): + """Initialize the test.""" + self.name = name + self._configure_server() + self._configure_traffic_server() + self._configure_client() + + def _configure_server(self): + """Configure the Proxy Verifier origin server.""" + self._server = Test.MakeVerifierServerProcess( + "server-go-h3-client", self.replay_file, verbose=False, other_args="--poll-timeout 30000") + + def _configure_traffic_server(self): + """Configure Traffic Server.""" + ts = Test.MakeATSProcess("ts-go-h3-client", enable_tls=True, enable_quic=True, enable_cache=False) + ts.StartupTimeout = 60 + ts.addDefaultSSLFiles() + add_default_ssl_multicert(ts) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'quic|http3', + 'proxy.config.quic.initial_max_data_in': 1000000, + 'proxy.config.quic.initial_max_stream_data_bidi_remote_in': 1000000, + 'proxy.config.quic.server.stateless_retry_enabled': 0, + 'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir, + 'proxy.config.ssl.server.private_key.path': ts.Variables.SSLDir, + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.http_port}') + ts.Disk.logging_yaml.AddLines( + ''' +logging: + formats: + - name: h3_go_access + format: 'c_alpn=% client_version=% c_method=% c_url=%' + + logs: + - filename: h3_go_access + format: h3_go_access +'''.split("\n")) + + self._access_log = Test.Disk.File(os.path.join(ts.Variables.LOGDIR, 'h3_go_access.log'), exists=True) + self._access_log.Content = Testers.ContainsExpression( + r'c_alpn=h3 client_version=http/3 c_method=GET c_url=https://go\.example\.com:[0-9]+/go-get-empty', + "ATS should log the quic-go request as HTTP/3") + self._access_log.Content += Testers.ContainsExpression( + r'c_alpn=h3 client_version=http/3 c_method=POST c_url=https://go\.example\.com:[0-9]+/go-post-large', + "ATS should log the quic-go large POST as HTTP/3") + + self._ts = ts + + def _configure_client(self): + """Configure the quic-go client test runs.""" + tr = Test.AddTestRun(self.name) + tr.Setup.Copy("go_h3_client") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Env['GOFLAGS'] = '-mod=readonly' + tr.Processes.Default.Env['GOCACHE'] = os.path.join(tr.RunDirectory, 'gocache') + tr.Processes.Default.Env['GOMODCACHE'] = os.path.join(tr.RunDirectory, 'gomodcache') + tr.Processes.Default.Env['GOTOOLCHAIN'] = 'local' + tr.Processes.Default.Command = ( + f'cd "{os.path.join(tr.RunDirectory, "go_h3_client")}" && ' + f'go run . --addr 127.0.0.1:{self._ts.Variables.ssl_port} ' + f'--authority go.example.com:{self._ts.Variables.ssl_port} ' + '--server-name go.example.com') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression( + "completed 13 HTTP/3 requests", "The quic-go client should complete all HTTP/3 requests.") + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + tr = Test.AddTestRun("Wait for quic-go HTTP/3 access log") + tr.Processes.Default.Command = ( + os.path.join(Test.Variables.AtsTestToolsDir, 'condwait') + ' 60 1 -f ' + + os.path.join(self._ts.Variables.LOGDIR, 'h3_go_access.log')) + tr.Processes.Default.ReturnCode = 0 + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + +TestHttp3GoClient("quic-go HTTP/3 client requests") diff --git a/tests/gold_tests/h3/h3_h2_origin.test.py b/tests/gold_tests/h3/h3_h2_origin.test.py new file mode 100644 index 00000000000..afa03799062 --- /dev/null +++ b/tests/gold_tests/h3/h3_h2_origin.test.py @@ -0,0 +1,26 @@ +''' +Verify HTTP/3 client traffic to an HTTP/2 origin. +''' +# 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. + +Test.Summary = ''' +Verify that HTTP/3 client requests are proxied correctly to an HTTP/2 origin. +''' + +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) + +Test.ATSReplayTest(replay_file="replays/h3_h2_origin.replay.yaml") diff --git a/tests/gold_tests/h3/h3_proxy_verifier.test.py b/tests/gold_tests/h3/h3_proxy_verifier.test.py new file mode 100644 index 00000000000..a32b21dfd59 --- /dev/null +++ b/tests/gold_tests/h3/h3_proxy_verifier.test.py @@ -0,0 +1,27 @@ +''' +Verify HTTP/3 client interop with Proxy Verifier. +''' +# 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. + +Test.Summary = ''' +Verify a real HTTP/3 Proxy Verifier client can complete multiple transactions +across multiple QUIC connections through ATS. +''' + +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) + +Test.ATSReplayTest(replay_file="replays/h3_proxy_verifier.replay.yaml") diff --git a/tests/gold_tests/h3/h3_python_client.test.py b/tests/gold_tests/h3/h3_python_client.test.py new file mode 100644 index 00000000000..2d809977ef4 --- /dev/null +++ b/tests/gold_tests/h3/h3_python_client.test.py @@ -0,0 +1,132 @@ +''' +Verify HTTP/3 client interop with an aioquic Python client. +''' +# 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 = ''' +Verify that an aioquic HTTP/3 client can complete normal requests and selected +HTTP/3 edge-case probes through ATS. +''' + +Test.SkipUnless( + Condition.HasATSFeature('TS_USE_QUIC'), + Condition.HasProgram("python3", "python3 is required for the aioquic HTTP/3 client"), +) + + +def add_default_ssl_multicert(ts): + """Configure the default server certificate.""" + if hasattr(ts.Disk, "ssl_multicert_yaml"): + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + else: + ts.Disk.ssl_multicert_config.AddLine("dest_ip=* ssl_cert_name=server.pem ssl_key_name=server.key") + + +class TestHttp3PythonClient: + """Configure a test to verify HTTP/3 aioquic client interoperability.""" + + replay_file = "replays/h3_server_for_python_client.replay.yaml" + + def __init__(self, name: str): + """Initialize the test.""" + self.name = name + self._configure_server() + self._configure_traffic_server() + self._configure_client() + + def _configure_server(self): + """Configure the Proxy Verifier origin server.""" + self._server = Test.MakeVerifierServerProcess( + "server-python-h3-client", self.replay_file, verbose=False, other_args="--poll-timeout 30000") + + def _configure_traffic_server(self): + """Configure Traffic Server.""" + ts = Test.MakeATSProcess("ts-python-h3-client", enable_tls=True, enable_quic=True, enable_cache=False) + ts.StartupTimeout = 60 + ts.addDefaultSSLFiles() + add_default_ssl_multicert(ts) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'quic|http3', + 'proxy.config.quic.initial_max_data_in': 1000000, + 'proxy.config.quic.initial_max_stream_data_bidi_remote_in': 1000000, + 'proxy.config.quic.max_send_udp_payload_size_in': 1200, + 'proxy.config.quic.server.stateless_retry_enabled': 0, + 'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir, + 'proxy.config.ssl.server.private_key.path': ts.Variables.SSLDir, + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.http_port}') + ts.Disk.logging_yaml.AddLines( + ''' +logging: + formats: + - name: h3_python_access + format: 'c_alpn=% client_version=% c_method=% c_url=%' + + logs: + - filename: h3_python_access + format: h3_python_access +'''.split("\n")) + + self._access_log = Test.Disk.File(os.path.join(ts.Variables.LOGDIR, 'h3_python_access.log'), exists=True) + self._access_log.Content = Testers.ContainsExpression( + r'c_alpn=h3 client_version=http/3 c_method=GET c_url=https://py\.example\.com:[0-9]+/py-get-empty', + "ATS should log the aioquic request as HTTP/3") + self._access_log.Content += Testers.ContainsExpression( + r'c_alpn=h3 client_version=http/3 c_method=PUT c_url=https://py\.example\.com:[0-9]+/py-put-large', + "ATS should log the aioquic large PUT as HTTP/3") + + self._ts = ts + + def _configure_client(self): + """Configure the aioquic client test runs.""" + tr = Test.AddTestRun(self.name) + tr.Setup.Copy("py_h3_client") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + client_dir = os.path.join(tr.RunDirectory, "py_h3_client") + tr.Processes.Default.Command = ( + f'"{sys.executable}" "{os.path.join(client_dir, "h3_client.py")}" ' + f'--addr 127.0.0.1:{self._ts.Variables.ssl_port} ' + f'--authority py.example.com:{self._ts.Variables.ssl_port} ' + '--server-name py.example.com') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression( + "completed 18 Python HTTP/3 checks", "The aioquic client should complete all HTTP/3 checks.") + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + tr = Test.AddTestRun("Wait for aioquic HTTP/3 access log") + tr.Processes.Default.Command = ( + os.path.join(Test.Variables.AtsTestToolsDir, 'condwait') + ' 60 1 -f ' + + os.path.join(self._ts.Variables.LOGDIR, 'h3_python_access.log')) + tr.Processes.Default.ReturnCode = 0 + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + +TestHttp3PythonClient("aioquic HTTP/3 client requests") diff --git a/tests/gold_tests/h3/h3_range_cache.test.py b/tests/gold_tests/h3/h3_range_cache.test.py new file mode 100644 index 00000000000..2222e2e47dc --- /dev/null +++ b/tests/gold_tests/h3/h3_range_cache.test.py @@ -0,0 +1,140 @@ +''' +Verify HTTP/3 range requests over cached content. +''' +# 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 + +Test.Summary = ''' +Verify that HTTP/3 clients can populate cache and receive range responses from +cached objects. +''' + +Test.SkipUnless( + Condition.HasATSFeature('TS_USE_QUIC'), + Condition.HasCurlFeature('http3'), + Condition.HasCurlOption('--http3-only'), +) +Test.SkipIf(Condition.CurlUsingUnixDomainSocket()) + + +def add_default_ssl_multicert(ts): + """Configure the default server certificate.""" + if hasattr(ts.Disk, "ssl_multicert_yaml"): + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + else: + ts.Disk.ssl_multicert_config.AddLine("dest_ip=* ssl_cert_name=server.pem ssl_key_name=server.key") + + +class TestHttp3RangeCache: + """Configure an HTTP/3 range-over-cache test.""" + + response_body = "0123456789" * 30000 + range_body = "6789012345678901" + + def __init__(self): + """Initialize the test.""" + self._configure_server() + self._configure_traffic_server() + self._configure_clients() + + def _configure_server(self): + """Configure the origin server.""" + server = Test.MakeOriginServer("server-h3-range-cache") + server.addResponse( + "sessionlog.json", { + "headers": "GET /h3-range-cache HTTP/1.1\r\nHost: localhost\r\n\r\n", + "timestamp": "1469733493.993", + "body": "" + }, { + "headers": + ( + "HTTP/1.1 200 OK\r\n" + "Connection: close\r\n" + "Cache-Control: public, max-age=60\r\n" + f"Content-Length: {len(self.response_body)}\r\n\r\n"), + "timestamp": "1469733493.993", + "body": self.response_body + }) + self._server = server + + def _configure_traffic_server(self): + """Configure Traffic Server.""" + ts = Test.MakeATSProcess("ts-h3-range-cache", enable_tls=True, enable_quic=True, enable_cache=True) + ts.StartupTimeout = 60 + ts.addDefaultSSLFiles() + add_default_ssl_multicert(ts) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'quic|http3|http', + 'proxy.config.quic.server.stateless_retry_enabled': 0, + 'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir, + 'proxy.config.ssl.server.private_key.path': ts.Variables.SSLDir, + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self._server.Variables.Port}') + self._ts = ts + + def _curl_base(self): + """Build the shared curl arguments.""" + return ( + '--silent --show-error --fail --ipv4 --http3-only --insecure ' + f'--resolve "range.example.com:{self._ts.Variables.ssl_port}:127.0.0.1" ' + f'https://range.example.com:{self._ts.Variables.ssl_port}/h3-range-cache') + + def _configure_clients(self): + """Configure the cache fill and range request clients.""" + full_body_path = os.path.join(Test.RunDirectory, "h3-range-full.txt") + range_body_path = os.path.join(Test.RunDirectory, "h3-range-part.txt") + + tr = Test.AddTestRun("HTTP/3 cache fill") + tr.Processes.Default.StartBefore(self._server) + tr.Processes.Default.StartBefore(self._ts) + tr.MakeCurlCommand( + f'{self._curl_base()} --output "{full_body_path}" ' + '--write-out "\\nhttp_code=%{http_code}\\nsize_download=%{size_download}\\n"', + ts=self._ts) + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression("http_code=200", "The fill request should return 200.") + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression( + f"size_download={len(self.response_body)}", "The fill request should receive the full object.") + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + tr = Test.AddTestRun("HTTP/3 cached range request") + tr.MakeCurlCommand( + f'{self._curl_base()} --header "Range: bytes=16-31" --output "{range_body_path}" ' + '--write-out "\\nhttp_code=%{http_code}\\nsize_download=%{size_download}\\n"', + ts=self._ts) + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression("http_code=206", "The range request should return 206.") + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression( + "size_download=16", "The range request should receive 16 bytes.") + Test.Disk.File( + range_body_path, exists=True).Content = Testers.ContainsExpression( + self.range_body, "The cached range response body should match the requested byte range.") + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + + +TestHttp3RangeCache() diff --git a/tests/gold_tests/h3/h3_session_ticket.sh b/tests/gold_tests/h3/h3_session_ticket.sh new file mode 100755 index 00000000000..ca7ade26397 --- /dev/null +++ b/tests/gold_tests/h3/h3_session_ticket.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# 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. + +set -euo pipefail + +session_option=$1 +session_file=$2 +port=$3 + +set +e +sleep 1 | timeout 5 openssl s_client \ + -quic \ + -alpn h3 \ + -connect "127.0.0.1:${port}" \ + -servername foo.com \ + "${session_option}" "${session_file}" \ + -brief \ + -ign_eof +status=${PIPESTATUS[1]} +set -e + +if [[ ${status} -ne 0 && ${status} -ne 1 && ${status} -ne 124 ]]; then + exit "${status}" +fi + +test -s "${session_file}" diff --git a/tests/gold_tests/h3/h3_session_ticket.test.py b/tests/gold_tests/h3/h3_session_ticket.test.py new file mode 100644 index 00000000000..fd839aad1cb --- /dev/null +++ b/tests/gold_tests/h3/h3_session_ticket.test.py @@ -0,0 +1,110 @@ +''' +Verify HTTP/3 QUIC TLS session ticket handling. +''' +# 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 shlex + +Test.Summary = ''' +Verify that HTTP/3 QUIC connections can receive and offer TLS session tickets. +''' + +Test.SkipUnless( + Condition.HasATSFeature('TS_USE_QUIC'), + Condition.HasOpenSSLVersion('3.5.0'), + Condition.HasOpenSSLQuicClient(), +) +Test.Setup.Copy('../tls/file.ticket') + + +def add_default_ssl_multicert(ts): + """Configure the default server certificate.""" + if hasattr(ts.Disk, "ssl_multicert_yaml"): + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + else: + ts.Disk.ssl_multicert_config.AddLine("dest_ip=* ssl_cert_name=server.pem ssl_key_name=server.key") + + +class TestHttp3SessionTicket: + """Configure an HTTP/3 QUIC TLS session ticket test.""" + + def __init__(self, name: str): + """Initialize the test.""" + self.name = name + self.session_file = os.path.join(Test.RunDirectory, "h3-quic-session.pem") + self.ticket_file = os.path.join(Test.RunDirectory, "file.ticket") + self._configure_traffic_server() + self._configure_ticket_save() + self._configure_ticket_reuse() + + def _configure_traffic_server(self): + """Configure Traffic Server.""" + ts = Test.MakeATSProcess("ts", enable_tls=True, enable_quic=True, enable_cache=False) + ts.StartupTimeout = 60 + ts.addDefaultSSLFiles() + add_default_ssl_multicert(ts) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'quic|ssl', + 'proxy.config.quic.server.stateless_retry_enabled': 0, + 'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir, + 'proxy.config.ssl.server.private_key.path': ts.Variables.SSLDir, + 'proxy.config.ssl.server.session_ticket.enable': 1, + 'proxy.config.ssl.server.session_ticket.number': 2, + 'proxy.config.ssl.server.ticket_key.filename': self.ticket_file, + }) + + self._ts = ts + + def _s_client_command(self, session_option: str): + """Build an OpenSSL QUIC client command for ticket save or reuse.""" + script = os.path.join(Test.TestDirectory, "h3_session_ticket.sh") + return f"{shlex.quote(script)} {session_option} {shlex.quote(self.session_file)} {self._ts.Variables.ssl_port}" + + def _check_s_client_handshake(self, tr): + """Verify that OpenSSL completed the QUIC handshake.""" + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.All = Testers.ContainsExpression( + "CONNECTION ESTABLISHED", "OpenSSL should complete the QUIC handshake.") + tr.Processes.Default.Streams.All += Testers.ContainsExpression( + "Protocol version: QUICv1", "OpenSSL should negotiate QUICv1.") + + def _configure_ticket_save(self): + """Configure the ticket save test run.""" + tr = Test.AddTestRun(self.name) + tr.Processes.Default.StartBefore(self._ts) + tr.Processes.Default.Command = f"rm -f {shlex.quote(self.session_file)}; {self._s_client_command('-sess_out')}" + self._check_s_client_handshake(tr) + tr.StillRunningAfter = self._ts + + def _configure_ticket_reuse(self): + """Configure the ticket reuse test run.""" + tr = Test.AddTestRun("OpenSSL QUIC offers saved session ticket") + tr.Processes.Default.Command = self._s_client_command("-sess_in") + self._check_s_client_handshake(tr) + tr.StillRunningAfter = self._ts + + +TestHttp3SessionTicket("OpenSSL QUIC saves session ticket") diff --git a/tests/gold_tests/h3/h3_sni_check.test.py b/tests/gold_tests/h3/h3_sni_check.test.py index 185ef70236d..febbf4ab3b4 100644 --- a/tests/gold_tests/h3/h3_sni_check.test.py +++ b/tests/gold_tests/h3/h3_sni_check.test.py @@ -21,7 +21,7 @@ Verify h3 SNI checking behavior. ''' -Test.SkipUnless(Condition.HasATSFeature('TS_HAS_QUICHE'), Condition.HasCurlFeature('http3')) +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) Test.ContinueOnFail = True @@ -67,9 +67,13 @@ def _configure_traffic_server(self, tr: 'TestRun'): self._ts = ts # Configure TLS for Traffic Server. self._ts.addDefaultSSLFiles() + self._ts.addSSLfile("../tls/ssl/signed-foo.pem") + self._ts.addSSLfile("../tls/ssl/signed-foo.key") self._ts.Disk.ssl_multicert_yaml.AddLines( """ ssl_multicert: + - ssl_cert_name: signed-foo.pem + ssl_key_name: signed-foo.key - dest_ip: "*" ssl_cert_name: server.pem ssl_key_name: server.key @@ -78,9 +82,9 @@ def _configure_traffic_server(self, tr: 'TestRun'): { 'proxy.config.diags.debug.enabled': 1, 'proxy.config.diags.debug.tags': 'http', - 'proxy.config.ssl.server.cert.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.ssl.server.cert.path': ts.Variables.SSLDir, 'proxy.config.quic.no_activity_timeout_in': 0, - 'proxy.config.ssl.server.private_key.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.ssl.server.private_key.path': ts.Variables.SSLDir, 'proxy.config.ssl.client.verify.server.policy': 'PERMISSIVE', }) diff --git a/tests/gold_tests/h3/h3_stream_lifetime.test.py b/tests/gold_tests/h3/h3_stream_lifetime.test.py new file mode 100644 index 00000000000..9715392ea78 --- /dev/null +++ b/tests/gold_tests/h3/h3_stream_lifetime.test.py @@ -0,0 +1,27 @@ +''' +Verify HTTP/3 stream lifetime handling with concurrent streams. +''' +# 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. + +Test.Summary = ''' +Verify HTTP/3 transactions survive concurrent stream close and write-ready +events on the same QUIC connection. +''' + +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) + +Test.ATSReplayTest(replay_file="replays/h3_stream_lifetime.replay.yaml") diff --git a/tests/gold_tests/h3/py_h3_client/h3_client.py b/tests/gold_tests/h3/py_h3_client/h3_client.py new file mode 100644 index 00000000000..63af49f0c25 --- /dev/null +++ b/tests/gold_tests/h3/py_h3_client/h3_client.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +# +# 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. +"""Exercise ATS HTTP/3 client-side behavior with aioquic.""" + +from __future__ import annotations + +import argparse +import asyncio +import ssl +from dataclasses import dataclass, field +from typing import Awaitable, Callable + +from aioquic.asyncio.client import connect +from aioquic.asyncio.protocol import QuicConnectionProtocol +from aioquic.buffer import encode_uint_var +from aioquic.h3.connection import H3Connection, H3_ALPN, FrameType, StreamType, encode_frame +from aioquic.h3.events import DataReceived, HeadersReceived +from aioquic.quic.configuration import QuicConfiguration +from aioquic.quic.events import ConnectionTerminated, QuicEvent, StreamDataReceived + +LARGE_BODY_SIZE = 300000 +LARGE_BODY_SUFFIX = b"000927b " +REUSED_HEADER_VALUE = b"stable-python-qpack-value" + + +@dataclass +class RequestCase: + """A single HTTP/3 request/response expectation.""" + + name: str + method: bytes + path: str + request_size: int = 0 + response_size: int = 0 + status: int = 200 + + +@dataclass +class ResponseState: + """Accumulate response headers and data for one HTTP/3 stream.""" + + header_blocks: list[list[tuple[bytes, bytes]]] = field(default_factory=list) + body: bytearray = field(default_factory=bytearray) + + @property + def status(self) -> int: + for header_block in reversed(self.header_blocks): + for name, value in header_block: + if name == b":status": + return int(value) + raise RuntimeError("response did not contain :status") + + +class H3ClientProtocol(QuicConnectionProtocol): + """Minimal HTTP/3 client protocol with raw QUIC stream helpers.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._http = H3Connection(self._quic) + self._responses: dict[int, ResponseState] = {} + self._waiters: dict[int, asyncio.Future[ResponseState]] = {} + self._raw_response_bytes: dict[int, int] = {} + self._event_counts: dict[str, int] = {} + self._terminated: asyncio.Future[ConnectionTerminated] = asyncio.get_running_loop().create_future() + + def quic_event_received(self, event: QuicEvent) -> None: + event_name = type(event).__name__ + self._event_counts[event_name] = self._event_counts.get(event_name, 0) + 1 + + if isinstance(event, ConnectionTerminated) and not self._terminated.done(): + self._terminated.set_result(event) + for waiter in self._waiters.values(): + if not waiter.done(): + waiter.set_exception(RuntimeError(f"connection terminated: {event.error_code} {event.reason_phrase}")) + + for http_event in self._http.handle_event(event): + if isinstance(http_event, HeadersReceived): + response = self._responses.setdefault(http_event.stream_id, ResponseState()) + response.header_blocks.append(http_event.headers) + if http_event.stream_ended: + self._complete_response(http_event.stream_id) + elif isinstance(http_event, DataReceived): + response = self._responses.setdefault(http_event.stream_id, ResponseState()) + response.body.extend(http_event.data) + if http_event.stream_ended: + self._complete_response(http_event.stream_id) + + if isinstance(event, StreamDataReceived): + self._raw_response_bytes[event.stream_id] = self._raw_response_bytes.get(event.stream_id, 0) + len(event.data) + if event.end_stream and event.stream_id in self._responses: + self._complete_response(event.stream_id) + + self.transmit() + + def _complete_response(self, stream_id: int) -> None: + waiter = self._waiters.get(stream_id) + if waiter is not None and not waiter.done(): + waiter.set_result(self._responses[stream_id]) + + async def request(self, authority: str, request_case: RequestCase) -> ResponseState: + stream_id = self._quic.get_next_available_stream_id() + waiter: asyncio.Future[ResponseState] = asyncio.get_running_loop().create_future() + self._waiters[stream_id] = waiter + + headers = [ + (b":method", request_case.method), + (b":scheme", b"https"), + (b":authority", authority.encode()), + (b":path", request_case.path.encode()), + (b"user-agent", b"ats-h3-aioquic-autest"), + (b"x-h3-python-client", b"aioquic"), + (b"x-h3-reused-header", REUSED_HEADER_VALUE), + (b"x-h3-test-case", request_case.name.encode()), + (b"uuid", request_case.name.encode()), + ] + if request_case.request_size > 0: + headers.extend( + [ + (b"content-type", b"application/octet-stream"), + (b"content-length", str(request_case.request_size).encode()), + ]) + + request_body = generated_body(request_case.request_size) + self._http.send_headers(stream_id, headers, end_stream=not request_body) + if request_body: + self._http.send_data(stream_id, request_body, end_stream=True) + self.transmit() + + try: + return await asyncio.wait_for(waiter, timeout=30) + except TimeoutError as e: + response = self._responses.get(stream_id) + raw_response_bytes = self._raw_response_bytes.get(stream_id, 0) + event_summary = ", ".join(f"{name}={count}" for name, count in sorted(self._event_counts.items())) + if response is None: + raise TimeoutError( + f"{request_case.name}: timed out before receiving response headers; raw QUIC stream bytes={raw_response_bytes}; " + f"events=[{event_summary}]") from e + raise TimeoutError( + f"{request_case.name}: timed out after receiving {len(response.header_blocks)} header block(s) and " + f"{len(response.body)} response byte(s); raw QUIC stream bytes={raw_response_bytes}; events=[{event_summary}]" + ) from e + + async def wait_for_termination(self) -> ConnectionTerminated: + return await asyncio.wait_for(self._terminated, timeout=5) + + def send_unknown_unidirectional_stream(self) -> None: + stream_id = self._quic.get_next_available_stream_id(is_unidirectional=True) + self._quic.send_stream_data(stream_id, encode_uint_var(0x21) + b"ignored", end_stream=True) + self.transmit() + + def send_client_push_stream(self) -> None: + stream_id = self._quic.get_next_available_stream_id(is_unidirectional=True) + self._quic.send_stream_data(stream_id, encode_uint_var(StreamType.PUSH) + encode_uint_var(0), end_stream=False) + self.transmit() + + def send_duplicate_control_stream(self) -> None: + stream_id = self._quic.get_next_available_stream_id(is_unidirectional=True) + payload = encode_uint_var(StreamType.CONTROL) + encode_frame(FrameType.SETTINGS, b"") + self._quic.send_stream_data(stream_id, payload, end_stream=False) + self.transmit() + + def send_reserved_request_frame(self) -> None: + stream_id = self._quic.get_next_available_stream_id() + self._quic.send_stream_data(stream_id, encode_frame(0x21, b""), end_stream=True) + self.transmit() + + def send_data_before_headers(self) -> None: + stream_id = self._quic.get_next_available_stream_id() + self._quic.send_stream_data(stream_id, encode_frame(FrameType.DATA, b"bad"), end_stream=True) + self.transmit() + + +def generated_body(size: int) -> bytes: + """Generate deterministic content matching Proxy Verifier size bodies.""" + chunks: list[bytes] = [] + total = 0 + value = 0 + while total < size: + chunk = f"{value:07x} ".encode() + chunks.append(chunk) + total += len(chunk) + value += 1 + return b"".join(chunks)[:size] + + +def quic_configuration(server_name: str) -> QuicConfiguration: + """Create an insecure test-only HTTP/3 client configuration.""" + configuration = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN, server_name=server_name) + configuration.verify_mode = ssl.CERT_NONE + return configuration + + +async def connect_h3(host: str, port: int, server_name: str): + """Open an HTTP/3 connection using the test protocol.""" + return connect(host, port, configuration=quic_configuration(server_name), create_protocol=H3ClientProtocol) + + +def verify_response(request_case: RequestCase, response: ResponseState) -> None: + """Verify one response matches the expected status and body.""" + body = bytes(response.body) + if response.status != request_case.status: + raise AssertionError(f"{request_case.name}: expected status {request_case.status}, got {response.status}") + + if request_case.method == b"HEAD" or request_case.status == 204: + if body: + raise AssertionError(f"{request_case.name}: expected no response body, got {len(body)} bytes") + return + + expected = generated_body(request_case.response_size) + if body != expected: + raise AssertionError(f"{request_case.name}: response body mismatch: got {len(body)}, expected {len(expected)}") + if request_case.response_size == LARGE_BODY_SIZE and not body.endswith(LARGE_BODY_SUFFIX): + raise AssertionError(f"{request_case.name}: large body suffix mismatch") + + +async def run_requests(host: str, port: int, authority: str, server_name: str, request_cases: list[RequestCase]) -> None: + """Run a sequence of request cases on one HTTP/3 connection.""" + async with await connect_h3(host, port, server_name) as client: + for request_case in request_cases: + response = await client.request(authority, request_case) + verify_response(request_case, response) + print(f"ok {request_case.name}") + + +async def run_concurrent_requests(host: str, port: int, authority: str, server_name: str, request_cases: list[RequestCase]) -> None: + """Run request cases concurrently on one HTTP/3 connection.""" + async with await connect_h3(host, port, server_name) as client: + responses = await asyncio.gather(*(client.request(authority, request_case) for request_case in request_cases)) + for request_case, response in zip(request_cases, responses): + verify_response(request_case, response) + print(f"ok {request_case.name}") + + +async def expect_connection_error( + host: str, + port: int, + server_name: str, + name: str, + action: Callable[[H3ClientProtocol], None], +) -> None: + """Run a malformed action and require ATS to close the QUIC connection.""" + async with await connect_h3(host, port, server_name) as client: + action(client) + terminated = await client.wait_for_termination() + if terminated.error_code == 0: + raise AssertionError(f"{name}: expected non-zero H3/QUIC close error") + print(f"ok {name} error={terminated.error_code}") + + +async def run_edge_cases(host: str, port: int, authority: str, server_name: str) -> None: + """Exercise H3 control stream and frame behavior with raw stream writes.""" + async with await connect_h3(host, port, server_name) as client: + client.send_unknown_unidirectional_stream() + request_case = RequestCase("py-edge-after-unknown", b"GET", "/py-edge-after-unknown", response_size=100) + response = await client.request(authority, request_case) + verify_response(request_case, response) + print("ok py-unknown-unidirectional-stream") + + await expect_connection_error( + host, port, server_name, "py-client-push-stream-rejected", lambda client: client.send_client_push_stream()) + await expect_connection_error( + host, port, server_name, "py-duplicate-control-stream-rejected", lambda client: client.send_duplicate_control_stream()) + async with await connect_h3(host, port, server_name) as client: + client.send_reserved_request_frame() + request_case = RequestCase("py-edge-after-reserved", b"GET", "/py-edge-after-reserved", response_size=100) + response = await client.request(authority, request_case) + verify_response(request_case, response) + print("ok py-reserved-request-frame-ignored") + + await expect_connection_error( + host, port, server_name, "py-data-before-headers-rejected", lambda client: client.send_data_before_headers()) + + +async def async_main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--addr", required=True, help="ATS HTTP/3 address in host:port form") + parser.add_argument("--authority", required=True, help="HTTP/3 request authority") + parser.add_argument("--server-name", required=True, help="TLS SNI server name") + args = parser.parse_args() + + host, port_text = args.addr.rsplit(":", 1) + port = int(port_text) + + sequential_cases = [ + RequestCase("py-get-empty", b"GET", "/py-get-empty"), + RequestCase("py-get-small", b"GET", "/py-get-small", response_size=100), + RequestCase("py-head-no-body", b"HEAD", "/py-head-no-body", response_size=100), + RequestCase("py-204-no-body", b"GET", "/py-204-no-body", status=204), + RequestCase("py-post-small", b"POST", "/py-post-small", request_size=100, response_size=100), + RequestCase("py-put-small", b"PUT", "/py-put-small", request_size=100, response_size=100), + RequestCase("py-delete-empty", b"DELETE", "/py-delete-empty", status=204), + RequestCase("py-options-small", b"OPTIONS", "/py-options-small", response_size=100), + ] + concurrent_cases = [ + RequestCase("py-get-concurrent-large", b"GET", "/py-get-concurrent-large", response_size=LARGE_BODY_SIZE), + RequestCase("py-get-concurrent-small", b"GET", "/py-get-concurrent-small", response_size=100), + ] + large_cases = [ + RequestCase("py-get-large", b"GET", "/py-get-large", response_size=LARGE_BODY_SIZE), + RequestCase("py-post-large", b"POST", "/py-post-large", request_size=LARGE_BODY_SIZE, response_size=LARGE_BODY_SIZE), + RequestCase("py-put-large", b"PUT", "/py-put-large", request_size=LARGE_BODY_SIZE, response_size=LARGE_BODY_SIZE), + ] + + await run_requests(host, port, args.authority, args.server_name, sequential_cases) + await run_requests(host, port, args.authority, args.server_name, large_cases) + await run_concurrent_requests(host, port, args.authority, args.server_name, concurrent_cases) + await run_edge_cases(host, port, args.authority, args.server_name) + print("completed 18 Python HTTP/3 checks") + + +def main() -> None: + asyncio.run(async_main()) + + +if __name__ == "__main__": + main() diff --git a/tests/gold_tests/h3/replays/h3_active_timeout.replay.yaml b/tests/gold_tests/h3/replays/h3_active_timeout.replay.yaml new file mode 100644 index 00000000000..f7a39324fd1 --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_active_timeout.replay.yaml @@ -0,0 +1,85 @@ +# 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. + +meta: + version: "1.0" + +autest: + description: "Verify HTTP/3 transaction cleanup on active timeout" + + server: + name: "server-h3-active-timeout" + + client: + name: "client-h3-active-timeout" + return_code: 1 + + ats: + name: "ts-h3-active-timeout" + startup_timeout: 60 + process_config: + enable_tls: true + enable_quic: true + + records_config: + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: "quic|http" + proxy.config.http.transaction_active_timeout_in: 1 + proxy.config.quic.no_activity_timeout_in: 0 + proxy.config.quic.server.stateless_retry_enabled: 0 + + remap_config: + - from: "https://example.com/" + to: "http://127.0.0.1:{SERVER_HTTP_PORT}/" + +sessions: +- protocol: + - name: http + version: 3 + - name: tls + sni: example.com + - name: udp + - name: ip + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-active-timeout ] + - [ uuid, h3-active-timeout ] + + server-response: + # Delay longer than the 1 second ATS active timeout configured by this replay. + delay: 2s + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "timeout-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrst" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] diff --git a/tests/gold_tests/h3/replays/h3_flow_control.replay.yaml b/tests/gold_tests/h3/replays/h3_flow_control.replay.yaml new file mode 100644 index 00000000000..0444a72eaef --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_flow_control.replay.yaml @@ -0,0 +1,132 @@ +# 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. + +meta: + version: "1.0" + +autest: + description: "Verify HTTP/3 flow control with small QUIC windows" + + server: + name: "server-h3-flow-control" + process_config: + verbose: false + other_args: "--poll-timeout 30000" + + client: + name: "client-h3-flow-control" + process_config: + verbose: false + other_args: "--poll-timeout 30000" + + ats: + name: "ts-h3-flow-control" + startup_timeout: 60 + process_config: + enable_tls: true + enable_quic: true + enable_cache: false + + records_config: + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: "quic|http3" + proxy.config.quic.initial_max_data_in: 4096 + proxy.config.quic.initial_max_stream_data_bidi_remote_in: 4096 + proxy.config.quic.server.stateless_retry_enabled: 0 + + remap_config: + - from: "https://example.com/" + to: "http://127.0.0.1:{SERVER_HTTP_PORT}/" + + log_validation: + traffic_out: + contains: + - expression: 'start HTTP/3 app \(ALPN=h3\)' + description: "ATS should negotiate HTTP/3" + +sessions: +- protocol: + stack: http3 + tls: + sni: example.com + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", POST ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-flow-post-large ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, h3-flow-post-large ] + content: + size: 300000 + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-flow-get-large ] + - [ uuid, h3-flow-get-large ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 diff --git a/tests/gold_tests/h3/replays/h3_h2_origin.replay.yaml b/tests/gold_tests/h3/replays/h3_h2_origin.replay.yaml new file mode 100644 index 00000000000..16a4f6b5aa3 --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_h2_origin.replay.yaml @@ -0,0 +1,352 @@ +# 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. + +meta: + version: "1.0" + +autest: + description: "Verify HTTP/3 client traffic to an HTTP/2 origin" + + server: + name: "server-h3-h2-origin" + process_config: + verbose: false + other_args: "--poll-timeout 30000" + + client: + name: "client-h3-h2-origin" + process_config: + verbose: false + other_args: "--poll-timeout 30000" + + ats: + name: "ts-h3-h2-origin" + startup_timeout: 60 + process_config: + enable_tls: true + enable_quic: true + enable_cache: false + + records_config: + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: "quic|http3|http2" + proxy.config.quic.initial_max_data_in: 1000000 + proxy.config.quic.initial_max_stream_data_bidi_remote_in: 1000000 + proxy.config.quic.server.stateless_retry_enabled: 0 + proxy.config.ssl.client.alpn_protocols: "h2,http/1.1" + proxy.config.ssl.client.verify.server.policy: PERMISSIVE + + remap_config: + - from: "https://example.com/" + to: "https://127.0.0.1:{SERVER_HTTPS_PORT}/" + + log_validation: + traffic_out: + contains: + - expression: 'start HTTP/3 app \(ALPN=h3\)' + description: "ATS should negotiate HTTP/3 on the client side" + +sessions: +- protocol: + stack: http3 + tls: + sni: example.com + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-get-empty ] + - [ uuid, h3-h2-get-empty ] + + proxy-request: + protocol: + stack: http2 + version: "2" + method: GET + scheme: https + url: /h3-h2-get-empty + + server-response: + version: "2" + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "0" ] + content: + size: 0 + + proxy-response: + version: "3" + status: 200 + headers: + fields: + - [ Content-Length, { value: "0", as: equal } ] + + - client-request: + version: "3" + await: h3-h2-get-empty + headers: + fields: + - [ ":method", HEAD ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-head-no-body ] + - [ uuid, h3-h2-head-no-body ] + + proxy-request: + protocol: + stack: http2 + version: "2" + method: HEAD + scheme: https + url: /h3-h2-head-no-body + + server-response: + version: "2" + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + + proxy-response: + version: "3" + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + + - client-request: + version: "3" + await: h3-h2-head-no-body + headers: + fields: + - [ ":method", OPTIONS ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-options-small ] + - [ uuid, h3-h2-options-small ] + + proxy-request: + protocol: + stack: http2 + version: "2" + method: OPTIONS + scheme: https + url: /h3-h2-options-small + + server-response: + version: "2" + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + content: + size: 100 + + proxy-response: + version: "3" + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + size: 100 + + - client-request: + version: "3" + await: h3-h2-options-small + headers: + fields: + - [ ":method", DELETE ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-delete-empty ] + - [ uuid, h3-h2-delete-empty ] + + proxy-request: + protocol: + stack: http2 + version: "2" + method: DELETE + scheme: https + url: /h3-h2-delete-empty + + server-response: + version: "2" + status: 204 + reason: No Content + headers: + fields: + - [ X-H3-Origin, h2-delete ] + + proxy-response: + version: "3" + status: 204 + +- protocol: + stack: http3 + tls: + sni: example.com + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", POST ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-post-small ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + - [ uuid, h3-h2-post-small ] + content: + size: 100 + + proxy-request: + protocol: + stack: http2 + version: "2" + method: POST + scheme: https + url: /h3-h2-post-small + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + size: 100 + + server-response: + version: "2" + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + content: + size: 100 + + proxy-response: + version: "3" + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + size: 100 + + - client-request: + version: "3" + await: h3-h2-post-small + headers: + fields: + - [ ":method", PUT ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-put-large ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, h3-h2-put-large ] + content: + size: 300000 + + proxy-request: + protocol: + stack: http2 + version: "2" + method: PUT + scheme: https + url: /h3-h2-put-large + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + + server-response: + version: "2" + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + version: "3" + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + +- protocol: + stack: http3 + tls: + sni: example.com + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-h2-get-large ] + - [ uuid, h3-h2-get-large ] + + proxy-request: + protocol: + stack: http2 + version: "2" + method: GET + scheme: https + url: /h3-h2-get-large + + server-response: + version: "2" + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + version: "3" + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 diff --git a/tests/gold_tests/h3/replays/h3_proxy_verifier.replay.yaml b/tests/gold_tests/h3/replays/h3_proxy_verifier.replay.yaml new file mode 100644 index 00000000000..817485859a5 --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_proxy_verifier.replay.yaml @@ -0,0 +1,481 @@ +# 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. + +meta: + version: "1.0" + +autest: + description: "Verify HTTP/3 client interop with Proxy Verifier across multiple connections" + + server: + name: "server-h3-proxy-verifier" + process_config: + verbose: false + other_args: "--poll-timeout 30000" + + client: + name: "client-h3-proxy-verifier" + process_config: + verbose: false + other_args: "--poll-timeout 30000" + + ats: + name: "ts-h3-proxy-verifier" + startup_timeout: 60 + process_config: + enable_tls: true + enable_quic: true + enable_cache: false + + records_config: + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: "quic|http3" + proxy.config.quic.initial_max_data_in: 1000000 + proxy.config.quic.initial_max_stream_data_bidi_remote_in: 1000000 + proxy.config.quic.server.stateless_retry_enabled: 0 + + remap_config: + - from: "https://example.com/" + to: "http://127.0.0.1:{SERVER_HTTP_PORT}/" + + log_validation: + traffic_out: + contains: + - expression: 'start HTTP/3 app \(ALPN=h3\)' + description: "ATS should negotiate HTTP/3" + +sessions: +- protocol: + - name: http + version: 3 + - name: tls + sni: example.com + - name: udp + - name: ip + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-get-empty ] + - [ uuid, h3-get-empty ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "0" ] + content: + size: 0 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "0", as: equal } ] + + - client-request: + version: "3" + await: h3-get-empty + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-get-small ] + - [ uuid, h3-get-small ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB" + verify: { as: equal } + + - client-request: + version: "3" + await: h3-get-small + headers: + fields: + - [ ":method", HEAD ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-head-no-body ] + - [ uuid, h3-head-no-body ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + + - client-request: + version: "3" + await: h3-head-no-body + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-204-no-body ] + - [ uuid, h3-204-no-body ] + + server-response: + status: 204 + reason: No Content + headers: + fields: + - [ Content-Length, "0" ] + - [ X-H3-Status, no-content ] + + proxy-response: + status: 204 + + - client-request: + version: "3" + await: h3-204-no-body + headers: + fields: + - [ ":method", POST ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-post-small ] + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + - [ uuid, h3-post-small ] + content: + encoding: plain + data: "post-body-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqr" + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "post-body-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqr" + verify: { as: equal } + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "post-response-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmn" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "post-response-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmn" + verify: { as: equal } + + - client-request: + version: "3" + await: h3-post-small + headers: + fields: + - [ ":method", PUT ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-put-small ] + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + - [ uuid, h3-put-small ] + content: + encoding: plain + data: "put-body-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrs" + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "put-body-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrs" + verify: { as: equal } + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "put-response-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmno" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "put-response-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmno" + verify: { as: equal } + + - client-request: + version: "3" + await: h3-put-small + headers: + fields: + - [ ":method", DELETE ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-delete-empty ] + - [ uuid, h3-delete-empty ] + + server-response: + status: 204 + reason: No Content + headers: + fields: + - [ X-H3-Status, delete-no-content ] + + proxy-response: + status: 204 + + - client-request: + version: "3" + await: h3-delete-empty + headers: + fields: + - [ ":method", OPTIONS ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-options-small ] + - [ uuid, h3-options-small ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Allow, "GET, HEAD, POST, PUT, DELETE, OPTIONS" ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "options-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrst" + + proxy-response: + status: 200 + headers: + fields: + - [ Allow, { value: "GET, HEAD, POST, PUT, DELETE, OPTIONS", as: equal } ] + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "options-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrst" + verify: { as: equal } + +- protocol: + - name: http + version: 3 + - name: tls + sni: example.com + - name: udp + - name: ip + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", POST ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-post-empty ] + - [ Content-Type, text/plain ] + - [ Content-Length, "0" ] + - [ uuid, h3-post-empty ] + content: + size: 0 + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "0", as: equal } ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "0" ] + content: + size: 0 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "0", as: equal } ] + + - client-request: + version: "3" + await: h3-post-empty + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-get-large ] + - [ uuid, h3-get-large ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + + - client-request: + version: "3" + await: h3-get-large + headers: + fields: + - [ ":method", PUT ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-put-large ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, h3-put-large ] + content: + size: 300000 + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + +- protocol: + - name: http + version: 3 + - name: tls + sni: example.com + - name: udp + - name: ip + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", POST ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-post-large ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, h3-post-large ] + content: + size: 300000 + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "300000", as: equal } ] + content: + size: 300000 diff --git a/tests/gold_tests/h3/replays/h3_server_for_go_client.replay.yaml b/tests/gold_tests/h3/replays/h3_server_for_go_client.replay.yaml new file mode 100644 index 00000000000..a7a475d8c3e --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_server_for_go_client.replay.yaml @@ -0,0 +1,268 @@ +# 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. + +meta: + version: "1.0" + + blocks: + - request_base: &request_base + version: "1.1" + - empty_response: &empty_response + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "0" ] + content: + size: 0 + - generated_100_response: &generated_100_response + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + content: + size: 100 + - generated_300k_response: &generated_300k_response + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + +sessions: +- transactions: + - client-request: + <<: *request_base + method: GET + url: /go-get-empty + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-get-empty ] + - [ X-H3-Test-Case, go-get-empty ] + + server-response: + <<: *empty_response + + - client-request: + <<: *request_base + method: GET + url: /go-get-small + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-get-small ] + - [ X-H3-Test-Case, go-get-small ] + + server-response: + <<: *generated_100_response + + - client-request: + <<: *request_base + method: HEAD + url: /go-head-no-body + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-head-no-body ] + - [ X-H3-Test-Case, go-head-no-body ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + + - client-request: + <<: *request_base + method: GET + url: /go-204-no-body + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-204-no-body ] + - [ X-H3-Test-Case, go-204-no-body ] + + server-response: + status: 204 + reason: No Content + headers: + fields: + - [ Content-Length, "0" ] + - [ X-H3-Status, no-content ] + + - client-request: + <<: *request_base + method: POST + url: /go-post-small + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + - [ uuid, go-post-small ] + - [ X-H3-Test-Case, go-post-small ] + content: + size: 100 + verify: { as: equal } + + server-response: + <<: *generated_100_response + + - client-request: + <<: *request_base + method: PUT + url: /go-put-small + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + - [ uuid, go-put-small ] + - [ X-H3-Test-Case, go-put-small ] + content: + size: 100 + verify: { as: equal } + + server-response: + <<: *generated_100_response + + - client-request: + <<: *request_base + method: DELETE + url: /go-delete-empty + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-delete-empty ] + - [ X-H3-Test-Case, go-delete-empty ] + + server-response: + status: 204 + reason: No Content + headers: + fields: + - [ X-H3-Status, delete-no-content ] + + - client-request: + <<: *request_base + method: OPTIONS + url: /go-options-small + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-options-small ] + - [ X-H3-Test-Case, go-options-small ] + + server-response: + <<: *generated_100_response + +- transactions: + - client-request: + <<: *request_base + method: GET + url: /go-get-concurrent-large + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-get-concurrent-large ] + - [ X-H3-Test-Case, go-get-concurrent-large ] + + server-response: + <<: *generated_300k_response + + - client-request: + <<: *request_base + method: GET + url: /go-get-concurrent-small + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-get-concurrent-small ] + - [ X-H3-Test-Case, go-get-concurrent-small ] + + server-response: + <<: *generated_100_response + +- transactions: + - client-request: + <<: *request_base + method: GET + url: /go-get-large + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ uuid, go-get-large ] + - [ X-H3-Test-Case, go-get-large ] + + server-response: + <<: *generated_300k_response + + - client-request: + <<: *request_base + method: POST + url: /go-post-large + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, go-post-large ] + - [ X-H3-Test-Case, go-post-large ] + content: + size: 300000 + verify: { as: equal } + + server-response: + <<: *generated_300k_response + + - client-request: + <<: *request_base + method: PUT + url: /go-put-large + headers: + fields: + - [ X-H3-Go-Client, quic-go ] + - [ X-H3-Reused-Header, stable-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, go-put-large ] + - [ X-H3-Test-Case, go-put-large ] + content: + size: 300000 + verify: { as: equal } + + server-response: + <<: *generated_300k_response diff --git a/tests/gold_tests/h3/replays/h3_server_for_python_client.replay.yaml b/tests/gold_tests/h3/replays/h3_server_for_python_client.replay.yaml new file mode 100644 index 00000000000..2ccf00799bd --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_server_for_python_client.replay.yaml @@ -0,0 +1,297 @@ +# 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. + +meta: + version: "1.0" + + blocks: + - request_base: &request_base + version: "1.1" + - empty_response: &empty_response + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "0" ] + content: + size: 0 + - generated_100_response: &generated_100_response + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + content: + size: 100 + - generated_300k_response: &generated_300k_response + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + content: + size: 300000 + +sessions: +- transactions: + - client-request: + <<: *request_base + method: GET + url: /py-get-empty + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-get-empty ] + - [ X-H3-Test-Case, py-get-empty ] + + server-response: + <<: *empty_response + + - client-request: + <<: *request_base + method: GET + url: /py-get-small + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-get-small ] + - [ X-H3-Test-Case, py-get-small ] + + server-response: + <<: *generated_100_response + + - client-request: + <<: *request_base + method: HEAD + url: /py-head-no-body + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-head-no-body ] + - [ X-H3-Test-Case, py-head-no-body ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "0" ] + + - client-request: + <<: *request_base + method: GET + url: /py-204-no-body + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-204-no-body ] + - [ X-H3-Test-Case, py-204-no-body ] + + server-response: + status: 204 + reason: No Content + headers: + fields: + - [ X-H3-Status, no-content ] + + - client-request: + <<: *request_base + method: POST + url: /py-post-small + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + - [ uuid, py-post-small ] + - [ X-H3-Test-Case, py-post-small ] + content: + size: 100 + verify: { as: equal } + + server-response: + <<: *generated_100_response + + - client-request: + <<: *request_base + method: PUT + url: /py-put-small + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "100" ] + - [ uuid, py-put-small ] + - [ X-H3-Test-Case, py-put-small ] + content: + size: 100 + verify: { as: equal } + + server-response: + <<: *generated_100_response + + - client-request: + <<: *request_base + method: DELETE + url: /py-delete-empty + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-delete-empty ] + - [ X-H3-Test-Case, py-delete-empty ] + + server-response: + status: 204 + reason: No Content + headers: + fields: + - [ X-H3-Status, delete-no-content ] + + - client-request: + <<: *request_base + method: OPTIONS + url: /py-options-small + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-options-small ] + - [ X-H3-Test-Case, py-options-small ] + + server-response: + <<: *generated_100_response + +- transactions: + - client-request: + <<: *request_base + method: GET + url: /py-get-concurrent-large + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-get-concurrent-large ] + - [ X-H3-Test-Case, py-get-concurrent-large ] + + server-response: + <<: *generated_300k_response + + - client-request: + <<: *request_base + method: GET + url: /py-get-concurrent-small + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-get-concurrent-small ] + - [ X-H3-Test-Case, py-get-concurrent-small ] + + server-response: + <<: *generated_100_response + +- transactions: + - client-request: + <<: *request_base + method: GET + url: /py-get-large + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-get-large ] + - [ X-H3-Test-Case, py-get-large ] + + server-response: + <<: *generated_300k_response + + - client-request: + <<: *request_base + method: POST + url: /py-post-large + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, py-post-large ] + - [ X-H3-Test-Case, py-post-large ] + content: + size: 300000 + verify: { as: equal } + + server-response: + <<: *generated_300k_response + + - client-request: + <<: *request_base + method: PUT + url: /py-put-large + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ Content-Type, application/octet-stream ] + - [ Content-Length, "300000" ] + - [ uuid, py-put-large ] + - [ X-H3-Test-Case, py-put-large ] + content: + size: 300000 + verify: { as: equal } + + server-response: + <<: *generated_300k_response + +- transactions: + - client-request: + <<: *request_base + method: GET + url: /py-edge-after-unknown + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-edge-after-unknown ] + - [ X-H3-Test-Case, py-edge-after-unknown ] + + server-response: + <<: *generated_100_response + +- transactions: + - client-request: + <<: *request_base + method: GET + url: /py-edge-after-reserved + headers: + fields: + - [ X-H3-Python-Client, aioquic ] + - [ X-H3-Reused-Header, stable-python-qpack-value ] + - [ uuid, py-edge-after-reserved ] + - [ X-H3-Test-Case, py-edge-after-reserved ] + + server-response: + <<: *generated_100_response diff --git a/tests/gold_tests/h3/replays/h3_sni.replay.yaml b/tests/gold_tests/h3/replays/h3_sni.replay.yaml index a4ca2bae174..55d23d08542 100644 --- a/tests/gold_tests/h3/replays/h3_sni.replay.yaml +++ b/tests/gold_tests/h3/replays/h3_sni.replay.yaml @@ -21,7 +21,7 @@ sessions: - protocol: stack: http3 tls: - sni: test_sni + sni: foo.com transactions: - client-request: @@ -31,7 +31,7 @@ sessions: - [ Content-Length, 0 ] - [:method, GET] - [:scheme, https] - - [:authority, example.com] + - [:authority, foo.com] - [:path, /path/test1] - [ uuid, has_sni ] server-response: @@ -55,7 +55,7 @@ sessions: - [ Content-Length, 0 ] - [:method, GET] - [:scheme, https] - - [:authority, example.com] + - [:authority, foo.com] - [:path, /path/test1] - [ uuid, no_sni ] server-response: diff --git a/tests/gold_tests/h3/replays/h3_stream_lifetime.replay.yaml b/tests/gold_tests/h3/replays/h3_stream_lifetime.replay.yaml new file mode 100644 index 00000000000..b263bea5277 --- /dev/null +++ b/tests/gold_tests/h3/replays/h3_stream_lifetime.replay.yaml @@ -0,0 +1,198 @@ +# 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. + +meta: + version: "1.0" + +autest: + description: "Verify HTTP/3 stream lifetime handling with concurrent streams" + + server: + name: "server-h3-stream-lifetime" + process_config: + verbose: false + + client: + name: "client-h3-stream-lifetime" + process_config: + verbose: false + + ats: + name: "ts-h3-stream-lifetime" + startup_timeout: 60 + process_config: + enable_tls: true + enable_quic: true + enable_cache: false + + records_config: + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: "quic|http3|v_http3_trans" + proxy.config.quic.server.stateless_retry_enabled: 0 + + remap_config: + - from: "https://example.com/" + to: "http://127.0.0.1:{SERVER_HTTP_PORT}/" + + log_validation: + traffic_out: + contains: + - expression: 'start HTTP/3 app \(ALPN=h3\)' + description: "ATS should negotiate HTTP/3" + +sessions: +- protocol: + - name: http + version: 3 + - name: tls + sni: example.com + - name: udp + - name: ip + + transactions: + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-slow ] + - [ uuid, h3-slow ] + + server-response: + delay: 500ms + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "slow-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvw" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "slow-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvw" + verify: { as: equal } + + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-empty ] + - [ uuid, h3-empty ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "0" ] + content: + size: 0 + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "0", as: equal } ] + + - client-request: + version: "3" + headers: + fields: + - [ ":method", GET ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-small ] + - [ uuid, h3-small ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "small-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuv" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "small-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuv" + verify: { as: equal } + + - client-request: + version: "3" + headers: + fields: + - [ ":method", POST ] + - [ ":scheme", https ] + - [ ":authority", example.com ] + - [ ":path", /h3-post ] + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + - [ uuid, h3-post ] + content: + encoding: plain + data: "post-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvw" + + proxy-request: + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "post-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvw" + verify: { as: equal } + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Type, text/plain ] + - [ Content-Length, "100" ] + content: + encoding: plain + data: "post-response-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmn" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "100", as: equal } ] + content: + encoding: plain + data: "post-response-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmn" + verify: { as: equal } diff --git a/tests/gold_tests/timeout/active_timeout.test.py b/tests/gold_tests/timeout/active_timeout.test.py index 5227cc0e93c..f180340165a 100644 --- a/tests/gold_tests/timeout/active_timeout.test.py +++ b/tests/gold_tests/timeout/active_timeout.test.py @@ -66,7 +66,7 @@ tr3.MakeCurlCommand('-k -i --http2 https://127.0.0.1:{0}/file'.format(ts.Variables.ssl_port), ts=ts) tr3.Processes.Default.Streams.stdout = Testers.ContainsExpression("Activity Timeout", "Request should fail with active timeout") - if Condition.HasATSFeature('TS_HAS_QUICHE') and Condition.HasCurlFeature('http3'): + if Condition.HasATSFeature('TS_USE_QUIC') and Condition.HasCurlFeature('http3'): tr4 = Test.AddTestRun("tr") tr4.MakeCurlCommand('-k -i --http3 https://localhost:{0}/file'.format(ts.Variables.ssl_port), ts=ts) tr4.Processes.Default.Streams.stdout = Testers.ContainsExpression( diff --git a/tests/gold_tests/timeout/quic_no_activity_timeout.test.py b/tests/gold_tests/timeout/quic_no_activity_timeout.test.py index f50146f5bea..19b330097e2 100644 --- a/tests/gold_tests/timeout/quic_no_activity_timeout.test.py +++ b/tests/gold_tests/timeout/quic_no_activity_timeout.test.py @@ -16,7 +16,7 @@ Test.Summary = 'Basic checks on QUIC max_idle_timeout set by ts.quic.no_activity_timeout_in' -Test.SkipUnless(Condition.HasATSFeature('TS_HAS_QUICHE'), Condition.HasCurlFeature('http3')) +Test.SkipUnless(Condition.HasATSFeature('TS_USE_QUIC')) class Test_quic_no_activity_timeout: @@ -117,18 +117,19 @@ def run(self, check_for_max_idle_timeout=False): replay_keys="nodelays") test0.run() -test1 = Test_quic_no_activity_timeout( - "Test ts.quic.no_activity_timeout_in(quic max_idle_timeout) with a 5s delay", - no_activity_timeout_in=3000, # 3s `max_idle_timeout` - replay_keys="delay5s", - gold_file="gold/quic_no_activity_timeout.gold") -test1.run(check_for_max_idle_timeout=True) - -# QUIC Ignores the default_inactivity_timeout config, so the ts.quic.no_activity_timeout_in -# should be honor -test2 = Test_quic_no_activity_timeout( - "Ignoring default_inactivity_timeout and use the ts.quic.no_activity_timeout_in instead", - replay_keys="delay5s", - no_activity_timeout_in=3000, - extra_recs={'proxy.config.net.default_inactivity_timeout': 1}) -test2.run(check_for_max_idle_timeout=True) +if Condition.HasATSFeature('TS_HAS_QUICHE'): + test1 = Test_quic_no_activity_timeout( + "Test ts.quic.no_activity_timeout_in(quic max_idle_timeout) with a 5s delay", + no_activity_timeout_in=3000, # 3s `max_idle_timeout` + replay_keys="delay5s", + gold_file="gold/quic_no_activity_timeout.gold") + test1.run(check_for_max_idle_timeout=True) + + # QUIC Ignores the default_inactivity_timeout config, so the ts.quic.no_activity_timeout_in + # should be honored + test2 = Test_quic_no_activity_timeout( + "Ignoring default_inactivity_timeout and use the ts.quic.no_activity_timeout_in instead", + replay_keys="delay5s", + no_activity_timeout_in=3000, + extra_recs={'proxy.config.net.default_inactivity_timeout': 1}) + test2.run(check_for_max_idle_timeout=True) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index d055f938c73..f976251df3b 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "pyOpenSSL", "eventlet", + "aioquic==1.3.0", # To test stats_over_http prometheus exporter. "prometheus_client", @@ -62,4 +63,3 @@ dev = [ "pyflakes", ] -