From 21c93e82342090858a9d5e8418683de3b9e1d797 Mon Sep 17 00:00:00 2001 From: bneradt Date: Fri, 6 Mar 2026 15:55:59 -0600 Subject: [PATCH] Add origin on_connect failure simulation Allow replay files to define how the verifier server behaves after reading a request. The new server-response.on_connect directive supports accept, refuse, and reset so tests can model upstream connection failures without synthesizing an HTTP response. The patch updates YAML parsing, schema validation, and server handling, and adds unit and AuTest coverage for the new behavior in the refactored test layout. Fixes: #331 --- README.md | 24 +++- schema/replay_schema.json | 22 +++- src/core/YamlParser.cc | 30 +++++ src/core/YamlParser.h | 11 ++ src/core/http.h | 7 ++ src/server/verifier-server.cc | 39 +++++- .../gold_tests/on_connect/on_connect.test.py | 54 +++++++++ .../on_connect/replay_files/on_connect.yaml | 62 ++++++++++ tests/unit_tests/test_YamlParser.cc | 111 ++++++++++++++++++ 9 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 tests/autests/gold_tests/on_connect/on_connect.test.py create mode 100644 tests/autests/gold_tests/on_connect/replay_files/on_connect.yaml diff --git a/README.md b/README.md index 4a7294bef..5bac2639f 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,8 @@ take the following nodes: such as `200` or `404`. 1. `reason`: This takes a string that describes the status, such as `"OK"` or `"Not Found"`. +1. `on_connect`: This controls the transport behavior after the request is + read. Supported values are `accept` (default), `refuse`, and `reset`. Here's an example of an HTTP/1 `server-response` with a status of 200, four fields, and a body of size 3,432 bytes: @@ -279,6 +281,25 @@ in this case) as opposed to the generated content specified by the * Points ``` +The `on_connect` directive can be used to simulate origin-side connection +failures without sending an HTTP response. This is useful when testing proxy +behavior for upstream errors. Supported values are: + +* `accept`: Normal behavior. The Verifier server sends the configured HTTP + response. This is the default. +* `refuse`: The Verifier server closes the upstream connection after it reads + and validates the request, without sending a response. +* `reset`: The Verifier server aborts the upstream connection after it reads + and validates the request, without sending a response. + +When `on_connect` is set to `refuse` or `reset`, the `status` field becomes +optional because no response is sent. For example: + +```YAML + server-response: + on_connect: refuse +``` + #### Server Response Lookup The `client-request` and `server-response` nodes are all that is required to @@ -881,7 +902,8 @@ Note that this example specifies the following delays: * The client also delays 15 milliseconds before sending the client request. * The server delays 17 milliseconds (17,000 microseconds) before sending the - corresponding response after receiving the request. + corresponding response after receiving the request. This same delay is + applied before `server-response.on_connect: refuse` or `reset` actions. Be aware of the following characteristics of the `delay` node: diff --git a/schema/replay_schema.json b/schema/replay_schema.json index 3f9f470ca..d181d88df 100644 --- a/schema/replay_schema.json +++ b/schema/replay_schema.json @@ -246,8 +246,28 @@ "title": "response", "description": "HTTP response.", "type": "object", - "required": ["status"], + "allOf": [ + { + "if": { + "properties": { + "on_connect": { + "enum": ["refuse", "reset"] + } + }, + "required": ["on_connect"] + }, + "then": {}, + "else": { + "required": ["status"] + } + } + ], "properties": { + "on_connect": { + "description": "Connection action to take after reading the request.", + "type": "string", + "enum": ["accept", "refuse", "reset"] + }, "version": { "description": "HTTP version", "type": "string", diff --git a/src/core/YamlParser.cc b/src/core/YamlParser.cc index 8367dbbcd..185adb1b7 100644 --- a/src/core/YamlParser.cc +++ b/src/core/YamlParser.cc @@ -314,6 +314,36 @@ get_delay_time(YAML::Node const &node) return zret; } +swoc::Rv +get_on_connect_action(YAML::Node const &node) +{ + swoc::Rv zret{Txn::ConnectAction::ACCEPT}; + auto on_connect_node{node[YAML_HTTP_ON_CONNECT_KEY]}; + if (!on_connect_node) { + return zret; + } + if (!on_connect_node.IsScalar()) { + zret.note(S_ERROR, R"("{}" key that is not a scalar.)", YAML_HTTP_ON_CONNECT_KEY); + return zret; + } + + auto const action = on_connect_node.Scalar(); + if (action == "accept") { + zret = Txn::ConnectAction::ACCEPT; + } else if (action == "refuse") { + zret = Txn::ConnectAction::REFUSE; + } else if (action == "reset") { + zret = Txn::ConnectAction::RESET; + } else { + zret.note( + S_ERROR, + R"(Unrecognized "{}" value "{}". Expected one of: accept, refuse, reset.)", + YAML_HTTP_ON_CONNECT_KEY, + action); + } + return zret; +} + Errata validate_psuedo_headers(const HttpHeader &hdr, int number_of_pseudo_headers) { diff --git a/src/core/YamlParser.h b/src/core/YamlParser.h index 3b6f63cbf..69679ac44 100644 --- a/src/core/YamlParser.h +++ b/src/core/YamlParser.h @@ -17,6 +17,8 @@ #include "yaml-cpp/yaml.h" +#include "core/http.h" + #include "swoc/BufferWriter.h" #include "swoc/Errata.h" #include "swoc/MemArena.h" @@ -71,6 +73,7 @@ static const std::string YAML_HTTP_REASON_KEY{"reason"}; static const std::string YAML_HTTP_METHOD_KEY{"method"}; static const std::string YAML_HTTP_SCHEME_KEY{"scheme"}; static const std::string YAML_HTTP_VERSION_KEY{"version"}; +static const std::string YAML_HTTP_ON_CONNECT_KEY{"on_connect"}; static const std::string YAML_HTTP_AWAIT_KEY{"await"}; static const std::string YAML_HTTP2_KEY{"http2"}; static const std::string YAML_HTTP2_PSEUDO_METHOD_KEY{":method"}; @@ -132,6 +135,14 @@ swoc::Rv interpret_delay_string(swoc::TextView delay) */ swoc::Rv get_delay_time(YAML::Node const &node); +/** Parse the value of a server-response "on_connect" directive. + * + * @param[in] node The server-response node containing the directive. + * @return The parsed connect action. If the directive is absent, ACCEPT is + * returned. Errors are returned for non-scalar or unrecognized values. + */ +swoc::Rv get_on_connect_action(YAML::Node const &node); + struct VerificationConfig { std::shared_ptr txn_rules; diff --git a/src/core/http.h b/src/core/http.h index 63e4ccab6..177dcc10b 100644 --- a/src/core/http.h +++ b/src/core/http.h @@ -638,10 +638,17 @@ struct Txn { Txn(bool verify_strictly) : _req{verify_strictly}, _rsp{verify_strictly} { } + enum class ConnectAction { + ACCEPT, + REFUSE, + RESET, + }; + std::chrono::nanoseconds _start; ///< The delay since the beginning of the session. /// How long the user said to delay for this transaction. std::chrono::microseconds _user_specified_delay_duration{0}; + ConnectAction _connect_action{ConnectAction::ACCEPT}; HttpHeader _req; ///< Request to send. HttpHeader _rsp; ///< Rules for response to expect. HttpHeader _rsp_trailer; ///< Rules for response trailer to expect. diff --git a/src/server/verifier-server.cc b/src/server/verifier-server.cc index 4c67f3434..62c0deded 100644 --- a/src/server/verifier-server.cc +++ b/src/server/verifier-server.cc @@ -128,6 +128,23 @@ get_not_found_response(int64_t stream_id, HTTP_PROTOCOL_TYPE protocol, std::stri return response; } +namespace +{ +void +perform_connect_action(Session &session, Txn::ConnectAction action) +{ + if (action == Txn::ConnectAction::RESET) { + if (session.get_fd() >= 0) { + struct linger l; + l.l_onoff = 1; + l.l_linger = 0; + setsockopt(session.get_fd(), SOL_SOCKET, SO_LINGER, (char *)&l, sizeof(l)); + } + } + session.close(); +} +} // namespace + std::thread ServerThreadPool::make_thread(std::thread *t) { @@ -504,7 +521,10 @@ ServerReplayFileHandler::server_response(YAML::Node const &node) { swoc::Errata errata; errata.note(YamlParser::populate_http_message(node, _txn._rsp)); - if (_txn._rsp._status == 0) { + auto &&[connect_action, on_connect_errata] = get_on_connect_action(node); + errata.note(std::move(on_connect_errata)); + _txn._connect_action = connect_action; + if (_txn._rsp._status == 0 && _txn._connect_action == Txn::ConnectAction::ACCEPT) { errata.note( S_ERROR, R"(server-response node without a status at "{}":{}.)", @@ -733,9 +753,20 @@ TF_Serve_Connection(std::thread *t) if (specified_transaction._user_specified_delay_duration > 0us) { sleep_for(specified_transaction._user_specified_delay_duration); } - auto &&[bytes_written, write_errata] = - thread_info._session->write(specified_transaction._rsp); - thread_errata.note(std::move(write_errata)); + if (specified_transaction._connect_action == Txn::ConnectAction::ACCEPT) { + auto &&[bytes_written, write_errata] = + thread_info._session->write(specified_transaction._rsp); + thread_errata.note(std::move(write_errata)); + } else { + thread_errata.note( + S_DIAG, + R"(Applying "{}" on_connect action for key {}.)", + specified_transaction._connect_action == Txn::ConnectAction::REFUSE ? "refuse" : + "reset", + key); + perform_connect_action(*thread_info._session, specified_transaction._connect_action); + break; + } } // cleanup and get ready for another session. diff --git a/tests/autests/gold_tests/on_connect/on_connect.test.py b/tests/autests/gold_tests/on_connect/on_connect.test.py new file mode 100644 index 000000000..c8baf16d8 --- /dev/null +++ b/tests/autests/gold_tests/on_connect/on_connect.test.py @@ -0,0 +1,54 @@ +''' +Verify server-response on_connect behavior. +''' +# @file +# +# Copyright 2022, Verizon Media +# SPDX-License-Identifier: Apache-2.0 +# + +Test.Summary = ''' +Verify server-response on_connect behavior. +''' + +r = Test.AddTestRun("Verify on_connect accept, refuse, and reset behavior") +client = r.AddClientProcess("client", "replay_files/on_connect.yaml", configure_https=False) +server = r.AddServerProcess("server", "replay_files/on_connect.yaml", configure_https=False) +proxy = r.AddProxyProcess("proxy", listen_port=client.Variables.http_port, + server_port=server.Variables.http_port) + +client.Streams.stdout += Testers.ContainsExpression( + "3 transactions in 3 sessions", "The client should have parsed all three transactions.") + +client.Streams.stdout += Testers.ContainsExpression( + "Received an HTTP/1 200 response for key 1", "The accept case should return a normal response.") + +client.Streams.stdout += Testers.ContainsExpression( + "Received an HTTP/1 502 response for key 2", "The refuse case should surface as a proxy 502.") + +client.Streams.stdout += Testers.ContainsExpression( + "Received an HTTP/1 502 response for key 3", "The reset case should surface as a proxy 502.") + +client.Streams.stdout += Testers.ExcludesExpression("Violation:", + "There should be no verification errors.") + +server.Streams.stdout += Testers.ContainsExpression( + 'Applying "refuse" on_connect action for key 2.', "The server should apply the refuse action.") + +server.Streams.stdout += Testers.ContainsExpression('Applying "reset" on_connect action for key 3.', + "The server should apply the reset action.") + +server.Streams.stdout += Testers.ContainsExpression( + "Sent the following HTTP/1 response headers for key 1", + "The accept case should still write a normal response.") + +server.Streams.stdout += Testers.ExcludesExpression( + "Sent the following HTTP/1 response headers for key 2", + "The refuse case should not write response headers.") + +server.Streams.stdout += Testers.ExcludesExpression( + "Sent the following HTTP/1 response headers for key 3", + "The reset case should not write response headers.") + +server.Streams.stdout += Testers.ExcludesExpression("Violation:", + "There should be no verification errors.") diff --git a/tests/autests/gold_tests/on_connect/replay_files/on_connect.yaml b/tests/autests/gold_tests/on_connect/replay_files/on_connect.yaml new file mode 100644 index 000000000..f007d9bc8 --- /dev/null +++ b/tests/autests/gold_tests/on_connect/replay_files/on_connect.yaml @@ -0,0 +1,62 @@ +# @file +# +# Copyright 2022, Verizon Media +# SPDX-License-Identifier: Apache-2.0 +# + +meta: + version: '1.0' + +sessions: +- transactions: + - client-request: + method: GET + url: http://example.com/accept + version: '1.1' + headers: + fields: + - [ Host, example.com ] + - [ uuid, 1 ] + + proxy-response: + status: 200 + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, '0' ] + - [ uuid, 1 ] + +- transactions: + - client-request: + method: GET + url: http://example.com/refuse + version: '1.1' + headers: + fields: + - [ Host, example.com ] + - [ uuid, 2 ] + + proxy-response: + status: 502 + + server-response: + on_connect: refuse + +- transactions: + - client-request: + method: GET + url: http://example.com/reset + version: '1.1' + headers: + fields: + - [ Host, example.com ] + - [ uuid, 3 ] + + proxy-response: + status: 502 + + server-response: + on_connect: reset diff --git a/tests/unit_tests/test_YamlParser.cc b/tests/unit_tests/test_YamlParser.cc index 538c37a25..b8cf29bd1 100644 --- a/tests/unit_tests/test_YamlParser.cc +++ b/tests/unit_tests/test_YamlParser.cc @@ -6,6 +6,7 @@ */ #include "catch.hpp" +#include "core/ProxyVerifier.h" #include "core/YamlParser.h" #include @@ -116,3 +117,113 @@ TEST_CASE("Verify interpretation of delay specification strings", "[delay_specif CHECK_FALSE(delay_errata.is_ok()); } } + +struct ParseOnConnectActionTestCase +{ + std::string const description; + std::string const yaml; + bool is_valid; + Txn::ConnectAction const expected_action; +}; + +std::initializer_list parse_on_connect_action_test_cases = { + { + .description = "Verify the on_connect directive defaults to accept.", + .yaml = "{}", + .is_valid = IS_VALID, + .expected_action = Txn::ConnectAction::ACCEPT, + }, + { + .description = "Verify accept is parsed.", + .yaml = "{ on_connect: accept }", + .is_valid = IS_VALID, + .expected_action = Txn::ConnectAction::ACCEPT, + }, + { + .description = "Verify refuse is parsed.", + .yaml = "{ on_connect: refuse }", + .is_valid = IS_VALID, + .expected_action = Txn::ConnectAction::REFUSE, + }, + { + .description = "Verify reset is parsed.", + .yaml = "{ on_connect: reset }", + .is_valid = IS_VALID, + .expected_action = Txn::ConnectAction::RESET, + }, + { + .description = "Verify invalid values fail parsing.", + .yaml = "{ on_connect: later }", + .is_valid = !IS_VALID, + .expected_action = Txn::ConnectAction::ACCEPT, + }, + { + .description = "Verify non-scalar values fail parsing.", + .yaml = "{ on_connect: [reset] }", + .is_valid = !IS_VALID, + .expected_action = Txn::ConnectAction::ACCEPT, + }, +}; + +TEST_CASE("Verify interpretation of on_connect actions", "[on_connect]") +{ + auto const &test_case = GENERATE(values(parse_on_connect_action_test_cases)); + auto const node = YAML::Load(test_case.yaml); + auto &&[action, action_errata] = get_on_connect_action(node); + if (test_case.is_valid) { + CHECK(action_errata.is_ok()); + CHECK(action == test_case.expected_action); + } else { + CHECK_FALSE(action_errata.is_ok()); + } +} + +struct ServerResponseValidationTestCase +{ + std::string const description; + std::string const yaml; + bool is_valid; +}; + +std::initializer_list server_response_validation_test_cases = { + { + .description = "Verify status is required without on_connect.", + .yaml = "{ reason: OK }", + .is_valid = !IS_VALID, + }, + { + .description = "Verify status is required with on_connect accept.", + .yaml = "{ on_connect: accept }", + .is_valid = !IS_VALID, + }, + { + .description = "Verify status is optional with on_connect refuse.", + .yaml = "{ on_connect: refuse }", + .is_valid = IS_VALID, + }, + { + .description = "Verify status is optional with on_connect reset.", + .yaml = "{ on_connect: reset }", + .is_valid = IS_VALID, + }, +}; + +TEST_CASE("Verify server-response validation for on_connect", "[on_connect]") +{ + auto const &test_case = GENERATE(values(server_response_validation_test_cases)); + auto const node = YAML::Load(test_case.yaml); + HttpHeader response{true}; + response.set_is_response(); + auto errata = YamlParser::populate_http_message(node, response); + auto &&[action, action_errata] = get_on_connect_action(node); + errata.note(std::move(action_errata)); + if (response._status == 0 && action == Txn::ConnectAction::ACCEPT) { + errata.note(S_ERROR, "server-response node is missing a required status."); + } + + if (test_case.is_valid) { + CHECK(errata.is_ok()); + } else { + CHECK_FALSE(errata.is_ok()); + } +}