This is an automated email from the ASF dual-hosted git repository. cmcfarlen pushed a commit to branch 10.2.x in repository https://gitbox.apache.org/repos/asf/trafficserver.git
commit 6f9f5672a4b88434158f9e365552993401ca56c6 Author: Masakazu Kitajo <[email protected]> AuthorDate: Thu Jul 16 11:56:04 2026 -0600 Hard-enforce max_active_streams_in at HTTP/2 stream creation (#13386) proxy.config.http2.max_active_streams_in has only adjusted the advertised SETTINGS_MAX_CONCURRENT_STREAMS, leaving the proxy unable to bound buffered response memory against clients that open streams faster than the advisory throttle reacts. It now also carries a finite default so the cap is no longer effectively unlimited. The new knob proxy.config.http2.max_active_streams_policy_in selects enforcement. Value 0 keeps the advisory behavior that lowers advertised concurrency to proxy.config.http2.min_concurrent_streams_in. Value 1 refuses new inbound streams with REFUSED_STREAM once the process-wide active-stream count reaches the limit and leaves the advertised value untouched, so the min_concurrent_streams_in reduction that disrupts some clients no longer applies. max_active_streams_in now defaults to 200000 rather than 0. Operators should size it to their available memory budget; set it to 0 to disable the cap. Refusal happens after HPACK decoding so the dynamic table stays in sync with the client and the connection survives. proxy.process.http2.max_active_streams_exceeded_in counts each refusal. (cherry picked from commit 154d8b00619b2c80c88857943fa10b09b0b516be) --- doc/admin-guide/files/records.yaml.en.rst | 37 ++++- include/proxy/http2/HTTP2.h | 2 + src/proxy/http2/HTTP2.cc | 45 +++--- src/proxy/http2/Http2ConnectionState.cc | 28 ++++ src/records/RecordsConfig.cc | 4 +- .../gold_tests/h2/clients/h2_max_active_streams.py | 173 +++++++++++++++++++++ .../gold_tests/h2/http2_max_active_streams.test.py | 86 ++++++++++ .../http2_max_active_streams_advisory.replay.yaml | 86 ++++++++++ .../http2_max_active_streams_enforce.replay.yaml | 55 +++++++ 9 files changed, 490 insertions(+), 26 deletions(-) diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 18d646a249..b8cc4b791e 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -4858,14 +4858,43 @@ HTTP/2 Configuration This is used when :ts:cv:`proxy.config.http2.max_active_streams_out` is set larger than ``0``. -.. ts:cv:: CONFIG proxy.config.http2.max_active_streams_in INT 0 +.. ts:cv:: CONFIG proxy.config.http2.max_active_streams_in INT 200000 :reloadable: - Limits the maximum number of connection wide active streams. - When connection wide active streams are larger than this value, + Limits the maximum number of process-wide active inbound streams. + When the process-wide active stream count reaches this value, SETTINGS_MAX_CONCURRENT_STREAMS will be reduced to :ts:cv:`proxy.config.http2.min_concurrent_streams_in`. - To disable, set to zero (``0``). + To disable, set to zero (``0``). The default bounds worst-case + buffered response memory while staying well above any realistic + legitimate stream count; size it to your available memory budget. + + See :ts:cv:`proxy.config.http2.max_active_streams_policy_in` to switch + from this advisory behavior to hard refusal of new streams once the + limit is reached. + +.. ts:cv:: CONFIG proxy.config.http2.max_active_streams_policy_in INT 0 + :reloadable: + + Selects how :ts:cv:`proxy.config.http2.max_active_streams_in` is + enforced for inbound HTTP/2 streams. + + ===== =================================================================== + Value Description + ===== =================================================================== + ``0`` When the limit is reached, the advertised + ``SETTINGS_MAX_CONCURRENT_STREAMS`` is reduced to + :ts:cv:`proxy.config.http2.min_concurrent_streams_in` for new + connections. Already-admitted streams are not refused. + ``1`` New inbound streams are refused with ``REFUSED_STREAM`` once the + global active-stream count reaches the limit. The advertised + ``SETTINGS_MAX_CONCURRENT_STREAMS`` is left unchanged and + :ts:cv:`proxy.config.http2.min_concurrent_streams_in` is not + applied. + ===== =================================================================== + + Has no effect when :ts:cv:`proxy.config.http2.max_active_streams_in` + is ``0``. .. ts:cv:: CONFIG proxy.config.http2.max_active_streams_out INT 0 :reloadable: diff --git a/include/proxy/http2/HTTP2.h b/include/proxy/http2/HTTP2.h index 9f3c62b97e..a8923924b6 100644 --- a/include/proxy/http2/HTTP2.h +++ b/include/proxy/http2/HTTP2.h @@ -108,6 +108,7 @@ struct Http2StatsBlock { Metrics::Counter::AtomicType *insufficient_avg_window_update; Metrics::Counter::AtomicType *max_concurrent_streams_exceeded_in; Metrics::Counter::AtomicType *max_concurrent_streams_exceeded_out; + Metrics::Counter::AtomicType *max_active_streams_exceeded_in; Metrics::Counter::AtomicType *data_frames_in; Metrics::Counter::AtomicType *headers_frames_in; Metrics::Counter::AtomicType *priority_frames_in; @@ -401,6 +402,7 @@ public: static uint32_t max_concurrent_streams_in; static uint32_t min_concurrent_streams_in; static uint32_t max_active_streams_in; + static uint32_t max_active_streams_policy_in; static bool throttling; static uint32_t stream_priority_enabled; static uint32_t initial_window_size_in; diff --git a/src/proxy/http2/HTTP2.cc b/src/proxy/http2/HTTP2.cc index 620ccc8f4e..c4765d5aab 100644 --- a/src/proxy/http2/HTTP2.cc +++ b/src/proxy/http2/HTTP2.cc @@ -459,16 +459,17 @@ http2_decode_header_blocks(HTTPHdr *hdr, const uint8_t *buf_start, const uint32_ } // Initialize this subsystem with librecords configs (for now) -uint32_t Http2::max_concurrent_streams_in = 100; -uint32_t Http2::min_concurrent_streams_in = 10; -uint32_t Http2::max_active_streams_in = 0; -bool Http2::throttling = false; -uint32_t Http2::stream_priority_enabled = 0; -uint32_t Http2::initial_window_size_in = 65535; -Http2FlowControlPolicy Http2::flow_control_policy_in = Http2FlowControlPolicy::STATIC_SESSION_AND_STATIC_STREAM; -uint32_t Http2::max_frame_size = 16384; -uint32_t Http2::header_table_size = 4096; -uint32_t Http2::max_header_list_size = 4294967295; +uint32_t Http2::max_concurrent_streams_in = 100; +uint32_t Http2::min_concurrent_streams_in = 10; +uint32_t Http2::max_active_streams_in = 200000; +uint32_t Http2::max_active_streams_policy_in = 0; +bool Http2::throttling = false; +uint32_t Http2::stream_priority_enabled = 0; +uint32_t Http2::initial_window_size_in = 65535; +Http2FlowControlPolicy Http2::flow_control_policy_in = Http2FlowControlPolicy::STATIC_SESSION_AND_STATIC_STREAM; +uint32_t Http2::max_frame_size = 16384; +uint32_t Http2::header_table_size = 4096; +uint32_t Http2::max_header_list_size = 4294967295; uint32_t Http2::accept_no_activity_timeout = 120; uint32_t Http2::no_activity_timeout_in = 120; @@ -513,6 +514,7 @@ Http2::init() RecEstablishStaticConfigUInt32(min_concurrent_streams_out, "proxy.config.http2.min_concurrent_streams_out"); RecEstablishStaticConfigUInt32(max_active_streams_in, "proxy.config.http2.max_active_streams_in"); + RecEstablishStaticConfigUInt32(max_active_streams_policy_in, "proxy.config.http2.max_active_streams_policy_in"); RecEstablishStaticConfigUInt32(stream_priority_enabled, "proxy.config.http2.stream_priority_enabled"); RecEstablishStaticConfigUInt32(initial_window_size_in, "proxy.config.http2.initial_window_size_in"); @@ -616,17 +618,18 @@ Http2::init() Metrics::Counter::createPtr("proxy.process.http2.max_concurrent_streams_exceeded_in"); http2_rsb.max_concurrent_streams_exceeded_out = Metrics::Counter::createPtr("proxy.process.http2.max_concurrent_streams_exceeded_out"); - http2_rsb.data_frames_in = Metrics::Counter::createPtr("proxy.process.http2.data_frames_in"), - http2_rsb.headers_frames_in = Metrics::Counter::createPtr("proxy.process.http2.headers_frames_in"), - http2_rsb.priority_frames_in = Metrics::Counter::createPtr("proxy.process.http2.priority_frames_in"), - http2_rsb.rst_stream_frames_in = Metrics::Counter::createPtr("proxy.process.http2.rst_stream_frames_in"), - http2_rsb.settings_frames_in = Metrics::Counter::createPtr("proxy.process.http2.settings_frames_in"), - http2_rsb.push_promise_frames_in = Metrics::Counter::createPtr("proxy.process.http2.push_promise_frames_in"), - http2_rsb.ping_frames_in = Metrics::Counter::createPtr("proxy.process.http2.ping_frames_in"), - http2_rsb.goaway_frames_in = Metrics::Counter::createPtr("proxy.process.http2.goaway_frames_in"), - http2_rsb.window_update_frames_in = Metrics::Counter::createPtr("proxy.process.http2.window_update_frames_in"), - http2_rsb.continuation_frames_in = Metrics::Counter::createPtr("proxy.process.http2.continuation_frames_in"), - http2_rsb.unknown_frames_in = Metrics::Counter::createPtr("proxy.process.http2.unknown_frames_in"), + http2_rsb.max_active_streams_exceeded_in = Metrics::Counter::createPtr("proxy.process.http2.max_active_streams_exceeded_in"); + http2_rsb.data_frames_in = Metrics::Counter::createPtr("proxy.process.http2.data_frames_in"), + http2_rsb.headers_frames_in = Metrics::Counter::createPtr("proxy.process.http2.headers_frames_in"), + http2_rsb.priority_frames_in = Metrics::Counter::createPtr("proxy.process.http2.priority_frames_in"), + http2_rsb.rst_stream_frames_in = Metrics::Counter::createPtr("proxy.process.http2.rst_stream_frames_in"), + http2_rsb.settings_frames_in = Metrics::Counter::createPtr("proxy.process.http2.settings_frames_in"), + http2_rsb.push_promise_frames_in = Metrics::Counter::createPtr("proxy.process.http2.push_promise_frames_in"), + http2_rsb.ping_frames_in = Metrics::Counter::createPtr("proxy.process.http2.ping_frames_in"), + http2_rsb.goaway_frames_in = Metrics::Counter::createPtr("proxy.process.http2.goaway_frames_in"), + http2_rsb.window_update_frames_in = Metrics::Counter::createPtr("proxy.process.http2.window_update_frames_in"), + http2_rsb.continuation_frames_in = Metrics::Counter::createPtr("proxy.process.http2.continuation_frames_in"), + http2_rsb.unknown_frames_in = Metrics::Counter::createPtr("proxy.process.http2.unknown_frames_in"), http2_frame_metrics_in[0] = http2_rsb.data_frames_in; http2_frame_metrics_in[1] = http2_rsb.headers_frames_in; diff --git a/src/proxy/http2/Http2ConnectionState.cc b/src/proxy/http2/Http2ConnectionState.cc index 372914223a..36d16d70d7 100644 --- a/src/proxy/http2/Http2ConnectionState.cc +++ b/src/proxy/http2/Http2ConnectionState.cc @@ -511,6 +511,17 @@ Http2ConnectionState::rcv_headers_frame(const Http2Frame &frame) "recv data bad payload length"); } + // Hard-enforce the global active-streams cap on inbound client streams. + if (!stream->is_outbound_connection() && !stream->trailing_header_is_possible() && Http2::max_active_streams_policy_in == 1 && + Http2::max_active_streams_in > 0) { + int64_t const current_streams = Metrics::Gauge::load(http2_rsb.current_client_stream_count); + if (current_streams >= Http2::max_active_streams_in) { + Metrics::Counter::increment(http2_rsb.max_active_streams_exceeded_in); + return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM, + "active streams cap reached"); + } + } + // Set up the State Machine if (!stream->is_outbound_connection() && !stream->trailing_header_is_possible()) { SCOPED_MUTEX_LOCK(stream_lock, stream->mutex, this_ethread()); @@ -1130,6 +1141,17 @@ Http2ConnectionState::rcv_continuation_frame(const Http2Frame &frame) "recv data bad payload length"); } + // Hard-enforce the global active-streams cap on inbound client streams. + if (!stream->is_outbound_connection() && !stream->trailing_header_is_possible() && Http2::max_active_streams_policy_in == 1 && + Http2::max_active_streams_in > 0) { + int64_t const current_streams = Metrics::Gauge::load(http2_rsb.current_client_stream_count); + if (current_streams >= Http2::max_active_streams_in) { + Metrics::Counter::increment(http2_rsb.max_active_streams_exceeded_in); + return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM, + "active streams cap reached"); + } + } + // Set up the State Machine SCOPED_MUTEX_LOCK(stream_lock, stream->mutex, this_ethread()); stream->mark_milestone(Http2StreamMilestone::START_TXN); @@ -2883,6 +2905,12 @@ Http2ConnectionState::_adjust_concurrent_stream() return max_concurrent_streams; } + if (!this->session->is_outbound() && Http2::max_active_streams_policy_in == 1) { + // Under hard-enforcement the advertised value is left untouched; new streams + // are refused at creation instead of throttling down concurrency. + return max_concurrent_streams; + } + int64_t current_client_streams = Metrics::Gauge::load(http2_rsb.current_client_stream_count); Http2ConDebug(session, "current client streams: %" PRId64, current_client_streams); diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc index c24839377e..5c0d0e4620 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -1348,7 +1348,9 @@ static constexpr RecordElement RecordsConfig[] = , {RECT_CONFIG, "proxy.config.http2.min_concurrent_streams_out", RECD_INT, "10", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL} , - {RECT_CONFIG, "proxy.config.http2.max_active_streams_in", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL} + {RECT_CONFIG, "proxy.config.http2.max_active_streams_in", RECD_INT, "200000", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL} + , + {RECT_CONFIG, "proxy.config.http2.max_active_streams_policy_in", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-1]$", RECA_NULL} , {RECT_CONFIG, "proxy.config.http2.max_active_streams_out", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL} , diff --git a/tests/gold_tests/h2/clients/h2_max_active_streams.py b/tests/gold_tests/h2/clients/h2_max_active_streams.py new file mode 100644 index 0000000000..e6aa01dd04 --- /dev/null +++ b/tests/gold_tests/h2/clients/h2_max_active_streams.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +''' +HTTP/2 client that opens N concurrent streams to test the global +active-streams cap and the HPACK dynamic-table sync across refused 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. + +import argparse +import socket +import ssl +import sys +from typing import Dict, Optional, Set, Tuple + +import hpack + +CONNECTION_PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' + +FRAME_TYPE_DATA = 0x00 +FRAME_TYPE_HEADERS = 0x01 +FRAME_TYPE_RST_STREAM = 0x03 +FRAME_TYPE_SETTINGS = 0x04 +FRAME_TYPE_GOAWAY = 0x07 + +FLAG_ACK = 0x01 +FLAG_END_STREAM = 0x01 +FLAG_END_HEADERS = 0x04 + +ERROR_REFUSED_STREAM = 0x07 + +# Reusable header value carried on streams >= the cap. Encoding the same +# (name, value) pair on a later stream forces hpack to emit an indexed +# reference into the dynamic-table entry added by the earlier encode, which +# only resolves correctly if ATS decoded the earlier (refused) HEADERS frame. +PROBE_HEADER_NAME = 'x-test-probe' +PROBE_HEADER_VALUE = 'shared-probe-value' + + +def make_socket(port: int) -> ssl.SSLSocket: + socket.setdefaulttimeout(15) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_alpn_protocols(['h2']) + + raw = socket.create_connection(('127.0.0.1', port)) + tls = ctx.wrap_socket(raw, server_hostname='localhost') + if tls.selected_alpn_protocol() != 'h2': + raise RuntimeError(f'failed to negotiate h2, got {tls.selected_alpn_protocol()!r}') + return tls + + +def make_frame(frame_type: int, flags: int = 0, stream_id: int = 0, payload: bytes = b'') -> bytes: + return (len(payload).to_bytes(3, 'big') + bytes([frame_type, flags]) + (stream_id & 0x7fffffff).to_bytes(4, 'big') + payload) + + +def read_exact(sock: ssl.SSLSocket, size: int) -> bytes: + chunks = [] + remaining = size + while remaining > 0: + chunk = sock.recv(remaining) + if not chunk: + raise EOFError('socket closed') + chunks.append(chunk) + remaining -= len(chunk) + return b''.join(chunks) + + +def read_frame(sock: ssl.SSLSocket) -> Tuple[int, int, int, bytes]: + header = read_exact(sock, 9) + length = int.from_bytes(header[0:3], 'big') + frame_type = header[3] + flags = header[4] + stream_id = int.from_bytes(header[5:9], 'big') & 0x7fffffff + payload = read_exact(sock, length) + return frame_type, flags, stream_id, payload + + +def request_block(encoder: hpack.Encoder, stream_id: int, include_probe: bool) -> bytes: + headers = [ + (':method', 'GET'), + (':scheme', 'https'), + (':authority', 'www.example.com'), + (':path', f'/stream/{stream_id}'), + ('uuid', f'max-active-streams-{stream_id}'), + ] + if include_probe: + headers.append((PROBE_HEADER_NAME, PROBE_HEADER_VALUE)) + return encoder.encode(headers) + + +def run(port: int, num_streams: int, probe_from: Optional[int]) -> int: + """Open @a num_streams concurrent streams. + + Returns 0 on success. + + Streams whose id is >= @a probe_from carry a fixed (name, value) header + so the second and later occurrences are encoded as indexed references + into the dynamic table established by the first occurrence. If ATS + fails to keep its decoder dynamic table in sync after refusing a + stream, the indexed reference fails to resolve and ATS sends a + COMPRESSION_ERROR GOAWAY, which this script reports as a failure. + """ + + stream_ids = [1 + 2 * i for i in range(num_streams)] + encoder = hpack.Encoder() + with make_socket(port) as sock: + sock.sendall(CONNECTION_PREFACE) + sock.sendall(make_frame(FRAME_TYPE_SETTINGS)) + for sid in stream_ids: + include_probe = probe_from is not None and sid >= probe_from + block = request_block(encoder, sid, include_probe) + sock.sendall(make_frame(FRAME_TYPE_HEADERS, FLAG_END_HEADERS | FLAG_END_STREAM, sid, block)) + + ended: Set[int] = set() + statuses: Dict[int, str] = {} + try: + while ended != set(stream_ids): + frame_type, flags, stream_id, payload = read_frame(sock) + if frame_type == FRAME_TYPE_SETTINGS and not (flags & FLAG_ACK): + sock.sendall(make_frame(FRAME_TYPE_SETTINGS, FLAG_ACK, 0)) + continue + if frame_type == FRAME_TYPE_GOAWAY: + error_code = int.from_bytes(payload[4:8], 'big') + print(f'GOAWAY error_code={error_code}') + return 1 + if stream_id in stream_ids: + if frame_type == FRAME_TYPE_RST_STREAM: + error_code = int.from_bytes(payload[0:4], 'big') + statuses[stream_id] = f'rst:{error_code}' + print(f'stream {stream_id}: RST_STREAM error_code={error_code}') + ended.add(stream_id) + elif frame_type in (FRAME_TYPE_DATA, FRAME_TYPE_HEADERS) and (flags & FLAG_END_STREAM): + statuses[stream_id] = 'end_stream' + print(f'stream {stream_id}: END_STREAM') + ended.add(stream_id) + except (EOFError, socket.timeout) as exc: + print(f'socket terminated before all streams ended: {exc}', file=sys.stderr) + return 1 + + for sid, status in sorted(statuses.items()): + print(f'final stream {sid}: {status}') + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('port', type=int, help='ATS TLS port') + parser.add_argument('--streams', type=int, default=4, help='number of concurrent streams to open') + parser.add_argument( + '--probe-from', + type=int, + default=None, + help='lowest stream id (odd) to carry the shared probe header; omit to disable probe') + args = parser.parse_args() + return run(args.port, args.streams, args.probe_from) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/gold_tests/h2/http2_max_active_streams.test.py b/tests/gold_tests/h2/http2_max_active_streams.test.py new file mode 100644 index 0000000000..42fb1d627f --- /dev/null +++ b/tests/gold_tests/h2/http2_max_active_streams.test.py @@ -0,0 +1,86 @@ +''' +Verify proxy.config.http2.max_active_streams_policy_in enforces the global +active-streams cap and that HPACK stays in sync across refused 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. + +import sys + +Test.Summary = ''' +HTTP/2 max_active_streams_policy_in enforcement and HPACK sync test. +''' + +CLIENT_SCRIPT = 'h2_max_active_streams.py' + + +class Http2MaxActiveStreamsTest: + """Drive concurrent inbound streams past max_active_streams_in.""" + + def __init__(self, name: str, replay_file: str, policy: int): + self._name = name + self._replay_file = replay_file + self._policy = policy + + def run(self) -> None: + tr = Test.AddTestRun(self._name) + server = tr.AddVerifierServerProcess(f'server-{self._name}', self._replay_file) + ts = tr.MakeATSProcess(f'ts-{self._name}', enable_tls=True, enable_cache=False) + + ts.addDefaultSSLFiles() + ts.Setup.CopyAs(f'clients/{CLIENT_SCRIPT}', Test.RunDirectory) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'http2', + 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}', + 'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}', + 'proxy.config.http2.max_active_streams_in': 2, + 'proxy.config.http2.max_active_streams_policy_in': self._policy, + 'proxy.config.http2.max_concurrent_streams_in': 100, + }) + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{server.Variables.http_port}') + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split('\n')) + + tr.Processes.Default.StartBefore(server) + tr.Processes.Default.StartBefore(ts) + tr.Processes.Default.Command = (f'{sys.executable} {CLIENT_SCRIPT} {ts.Variables.ssl_port} --streams 4 --probe-from 5') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression( + 'GOAWAY', 'ATS must not tear down the connection; HPACK dynamic table must stay in sync.') + + if self._policy == 1: + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression( + r'stream 5: RST_STREAM error_code=7', 'stream 5 must be refused with REFUSED_STREAM under enforce policy.') + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression( + r'stream 7: RST_STREAM error_code=7', + 'stream 7 must also be refused with REFUSED_STREAM, proving HPACK decode happened on stream 5.') + ts.Disk.traffic_out.Content += Testers.ContainsExpression( + r'HTTP/2 stream error code=0x07.*active streams cap reached', + 'ATS should log the cap-reached stream error under enforce policy.') + else: + tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression( + r'RST_STREAM error_code=7', 'No stream should be refused under advisory policy.') + + +Http2MaxActiveStreamsTest('enforce', 'replay/http2_max_active_streams_enforce.replay.yaml', policy=1).run() +Http2MaxActiveStreamsTest('advisory', 'replay/http2_max_active_streams_advisory.replay.yaml', policy=0).run() diff --git a/tests/gold_tests/h2/replay/http2_max_active_streams_advisory.replay.yaml b/tests/gold_tests/h2/replay/http2_max_active_streams_advisory.replay.yaml new file mode 100644 index 0000000000..8b7ee1b427 --- /dev/null +++ b/tests/gold_tests/h2/replay/http2_max_active_streams_advisory.replay.yaml @@ -0,0 +1,86 @@ +# 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. +# +# Origin replay for the global active-streams cap test in advisory mode. +# All four streams reach the origin since the proxy only adjusts the +# advertised SETTINGS_MAX_CONCURRENT_STREAMS rather than refusing +# streams. Responses are delayed so the gauge stays at the cap while +# later HEADERS frames are admitted. + +meta: + version: "1.0" + +sessions: +- transactions: + - client-request: + headers: + fields: + - [ :method, GET ] + - [ :scheme, https ] + - [ :authority, www.example.com ] + - [ :path, /stream/1 ] + - [ uuid, max-active-streams-1 ] + server-response: + delay: 1s + headers: + fields: + - [ :status, 200 ] + - [ Content-Length, 0 ] + + - client-request: + headers: + fields: + - [ :method, GET ] + - [ :scheme, https ] + - [ :authority, www.example.com ] + - [ :path, /stream/3 ] + - [ uuid, max-active-streams-3 ] + server-response: + delay: 1s + headers: + fields: + - [ :status, 200 ] + - [ Content-Length, 0 ] + + - client-request: + headers: + fields: + - [ :method, GET ] + - [ :scheme, https ] + - [ :authority, www.example.com ] + - [ :path, /stream/5 ] + - [ uuid, max-active-streams-5 ] + server-response: + delay: 1s + headers: + fields: + - [ :status, 200 ] + - [ Content-Length, 0 ] + + - client-request: + headers: + fields: + - [ :method, GET ] + - [ :scheme, https ] + - [ :authority, www.example.com ] + - [ :path, /stream/7 ] + - [ uuid, max-active-streams-7 ] + server-response: + delay: 1s + headers: + fields: + - [ :status, 200 ] + - [ Content-Length, 0 ] diff --git a/tests/gold_tests/h2/replay/http2_max_active_streams_enforce.replay.yaml b/tests/gold_tests/h2/replay/http2_max_active_streams_enforce.replay.yaml new file mode 100644 index 0000000000..651868516f --- /dev/null +++ b/tests/gold_tests/h2/replay/http2_max_active_streams_enforce.replay.yaml @@ -0,0 +1,55 @@ +# 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. +# +# Origin replay for the global active-streams cap test in enforce mode. +# Only streams 1 and 3 reach the origin; streams 5 and 7 are refused at +# the proxy. Responses are delayed so the cap stays tripped while the +# client sends the later HEADERS frames. + +meta: + version: "1.0" + +sessions: +- transactions: + - client-request: + headers: + fields: + - [ :method, GET ] + - [ :scheme, https ] + - [ :authority, www.example.com ] + - [ :path, /stream/1 ] + - [ uuid, max-active-streams-1 ] + server-response: + delay: 1s + headers: + fields: + - [ :status, 200 ] + - [ Content-Length, 0 ] + + - client-request: + headers: + fields: + - [ :method, GET ] + - [ :scheme, https ] + - [ :authority, www.example.com ] + - [ :path, /stream/3 ] + - [ uuid, max-active-streams-3 ] + server-response: + delay: 1s + headers: + fields: + - [ :status, 200 ] + - [ Content-Length, 0 ]
