From 4cf9b07ee8c9d8512bf275825f16ca4e598bf983 Mon Sep 17 00:00:00 2001 From: Brian Neradt Date: Tue, 18 Nov 2025 22:17:55 +0000 Subject: [PATCH] cookie_remap: set_sendto_headers This adds a new set_sendto_headers configuration option that allows setting arbitrary HTTP headers (including the Host header) on requests to sendto destinations when cookie rules match. Header values support dynamic substitution of regex capture groups ($1, $2, etc.) and path variables ($path, $cr_req_url, etc.). When the Host header is set via this option, pristine_host_hdr is automatically disabled for that transaction. --- doc/admin-guide/plugins/cookie_remap.en.rst | 105 ++++++++++ .../experimental/cookie_remap/cookie_remap.cc | 197 ++++++++++++++++-- .../configs/set_sendto_headers_config.yaml | 56 +++++ .../set_sendto_headers.replay.yaml | 177 ++++++++++++++++ .../cookie_remap/set_sendto_headers.test.py | 148 +++++++++++++ 5 files changed, 667 insertions(+), 16 deletions(-) create mode 100644 tests/gold_tests/pluginTest/cookie_remap/configs/set_sendto_headers_config.yaml create mode 100644 tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.replay.yaml create mode 100644 tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.test.py diff --git a/doc/admin-guide/plugins/cookie_remap.en.rst b/doc/admin-guide/plugins/cookie_remap.en.rst index 602daf2f89b..07c2460e35c 100644 --- a/doc/admin-guide/plugins/cookie_remap.en.rst +++ b/doc/admin-guide/plugins/cookie_remap.en.rst @@ -42,6 +42,7 @@ Cookie Based Routing Inside TrafficServer Using cookie_remap * :ref:`else: url [optional] ` * :ref:`connector: and ` * :ref:`disable_pristine_host_hdr: true|false [optional] ` + * :ref:`set_sendto_headers: [optional] ` * :ref:`Reserved path expressions ` @@ -233,6 +234,110 @@ This option only affects the successful match (``sendto``) path. The ``else`` path will continue to use the configured pristine host header setting (typically enabled in production environments). +.. _set-sendto-headers: + +set_sendto_headers: [optional] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Sets arbitrary HTTP request headers when a rule matches and takes the ``sendto`` +path. This provides a flexible way to modify request headers, including the +Host header, for redirected requests. + +Headers are only set when: + +* The operation matches successfully. +* The ``sendto`` path is taken (not the ``else`` path). + +**Format:** + +The value must be a YAML sequence (list) where each item is a single-key map +representing a header name and its value: + +.. code-block:: yaml + + set_sendto_headers: + - Header-Name: header-value + - Another-Header: another-value + +**Header Value Substitution:** + +The special variables mentioned below for regex capture groups, path variables, +URL variables, unmatched path, and URL encoding can be used in the header value. + +* **Regex capture groups**: ``$1``, ``$2``, ... ``$9`` (from regex operations). +* **Path variables**: ``$path``, ``$ppath`` (pre-remapped path). +* **URL variables**: ``$cr_req_url``, ``$cr_req_purl`` (pre-remapped URL). +* **Unmatched path**: ``$unmatched_path``, ``$unmatched_ppath``. +* **URL encoding**: ``$cr_urlencode(...)``. + +**Special Behavior for Host Header:** + +When ``set_sendto_headers`` includes a ``Host`` header (case-insensitive), the +pristine host header is automatically disabled for that transaction. This allows +the Host header to be updated to the specified value. You do not need to also +set ``disable_pristine_host_hdr: true`` in this case. + +**Interaction with disable_pristine_host_hdr:** + +* If ``set_sendto_headers`` sets the Host header, pristine host header is + automatically disabled. +* If ``set_sendto_headers`` does NOT set Host but ``disable_pristine_host_hdr`` + is ``true``, pristine host header is still disabled +* If neither condition applies, pristine host header behavior follows the + global configuration. + +**Examples:** + +Setting a static Host header to the bucketed destination: + +.. code-block:: yaml + + op: + cookie: SessionID + operation: exists + sendto: http://backend.internal.com/app + set_sendto_headers: + - Host: backend.internal.com + +Using regex capture groups in headers: + +.. code-block:: yaml + + op: + cookie: UserSegment + operation: regex + regex: (premium|standard) + sendto: http://$1.service.com/app + set_sendto_headers: + - Host: $1.service.com + - X-User-Tier: $1 + +Using path variables: + +.. code-block:: yaml + + op: + cookie: Debug + operation: exists + sendto: http://debug.example.com + set_sendto_headers: + - X-Original-Path: $path + - X-Original-URL: $cr_urlencode($cr_req_url) + +Multiple headers with bucket routing: + +.. code-block:: yaml + + op: + cookie: SessionID + operation: bucket + bucket: 10/100 + sendto: http://canary.example.com/app/$unmatched_path + set_sendto_headers: + - Host: canary.example.com + - X-Canary-Request: true + - X-Session-Bucket: canary + .. _reserved-path-expressions: Reserved path expressions diff --git a/plugins/experimental/cookie_remap/cookie_remap.cc b/plugins/experimental/cookie_remap/cookie_remap.cc index f29033f4c6d..33313a40e2d 100644 --- a/plugins/experimental/cookie_remap/cookie_remap.cc +++ b/plugins/experimental/cookie_remap/cookie_remap.cc @@ -453,6 +453,8 @@ class subop }; using SubOpQueue = std::vector>; +using HeaderPair = std::pair; +using HeaderList = std::vector; //---------------------------------------------------------------------------- class op @@ -514,6 +516,18 @@ class op return disable_pristine_host_hdr; } + void + addSendtoHeader(std::string_view const name, std::string_view const value) + { + sendto_headers.emplace_back(name, value); + } + + HeaderList const & + getSendtoHeaders() const + { + return sendto_headers; + } + void printOp() const { @@ -530,11 +544,17 @@ class op if (disable_pristine_host_hdr) { Dbg(dbg_ctl, "disable_pristine_host_hdr: true"); } + if (!sendto_headers.empty()) { + Dbg(dbg_ctl, "set_sendto_headers:"); + for (auto const &header : sendto_headers) { + Dbg(dbg_ctl, " %s: %s", header.first.c_str(), header.second.c_str()); + } + } } bool process(CookieJar &jar, std::string &dest, TSHttpStatus &retstat, TSRemapRequestInfo *rri, UrlComponents &req_url, - bool &used_sendto) const + bool &used_sendto, std::vector ®ex_match_strings, int ®ex_ccount) const { if (sendto == "") { return false; // guessing every operation must have a @@ -686,10 +706,19 @@ class op // OPERATION::regex matching if (subop_type == REGEXP) { - RegexMatches matches; - int ret = subop->regexMatch(string_to_match.c_str(), string_to_match.length(), matches); + RegexMatches regex_matches; + int ret = subop->regexMatch(string_to_match.c_str(), string_to_match.length(), regex_matches); if (ret >= 0) { + regex_ccount = subop->getRegexCcount(); // Store for later use in header substitution + + regex_match_strings.clear(); + regex_match_strings.reserve(regex_ccount + 1); + for (int i = 0; i <= regex_ccount; i++) { + auto const &match = regex_matches[i]; + regex_match_strings.emplace_back(match.data(), match.size()); + } + std::string::size_type pos = sendto.find('$'); std::string::size_type ppos = 0; @@ -717,9 +746,9 @@ class op if (isdigit(sendto[pos + 1])) { int ix = sendto[pos + 1] - '0'; - if (ix <= subop->getRegexCcount()) { // Just skip an illegal regex group + if (ix <= regex_ccount) { // Just skip an illegal regex group dest += sendto.substr(ppos, pos - ppos); - auto regex_match = matches[ix]; + auto regex_match = regex_matches[ix]; dest.append(regex_match.data(), regex_match.size()); ppos = pos + 2; } else { @@ -812,6 +841,7 @@ class op TSHttpStatus status = TS_HTTP_STATUS_NONE; TSHttpStatus else_status = TS_HTTP_STATUS_NONE; bool disable_pristine_host_hdr = false; + HeaderList sendto_headers{}; }; using StringPair = std::pair; @@ -854,6 +884,19 @@ build_op(op &o, OpMap const &q) o.setDisablePristineHostHdr(val == "true" || val == "1" || val == "yes"); } + if (key == "__set_sendto_header__") { + // Parse "header_name: header_value" format. We set this below in the TSRemapNewInstance function. + size_t const colon_pos = val.find(": "); + if (colon_pos != std::string::npos) { + std::string_view const header_name = std::string_view(val).substr(0, colon_pos); + std::string_view const header_value = std::string_view(val).substr(colon_pos + 2); + o.addSendtoHeader(header_name, header_value); + } else { + Dbg(dbg_ctl, "ERROR: invalid set_sendto_header format: %s", val.c_str()); + goto error; + } + } + if (key == "operation") { sub->setOperation(val); } @@ -933,16 +976,47 @@ TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSE for (YAML::const_iterator it2 = it->second.begin(); it2 != it->second.end(); ++it2) { const YAML::Node first = it2->first; const YAML::Node second = it2->second; + const string &key = first.as(); + + // Special handling for set_sendto_headers which is a sequence of maps + if (key == "set_sendto_headers") { + if (!second.IsSequence()) { + const string reason = "set_sendto_headers must be a sequence"; + TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str()); + return TS_ERROR; + } - if (second.IsScalar() == false) { - const string reason = "All op nodes must be of type scalar"; - TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str()); - return TS_ERROR; - } + for (const auto &header_node : second) { + if (!header_node.IsMap()) { + const string reason = "Each set_sendto_headers item must be a map"; + TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str()); + return TS_ERROR; + } + + // Each header should be a single-key map + if (header_node.size() != 1) { + const string reason = "Each set_sendto_headers item must be a single key-value pair"; + TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str()); + return TS_ERROR; + } - const string &key = first.as(); - const string &value = second.as(); - op_data.emplace_back(key, value); + for (const auto &kv : header_node) { + const string &header_name = kv.first.as(); + const string &header_value = kv.second.as(); + // Store with special prefix to identify in build_op + op_data.emplace_back("__set_sendto_header__", header_name + ": " + header_value); + } + } + } else { + if (second.IsScalar() == false) { + TSError("Invalid YAML Configuration format for cookie_remap: %s, non-scalar value for key: %s (type=%d)", + filename.c_str(), key.c_str(), second.Type()); + return TS_ERROR; + } + + const string &value = second.as(); + op_data.emplace_back(key, value); + } } if (op_data.size()) { @@ -1206,8 +1280,10 @@ TSRemapDoRemap(void *ih, TSHttpTxn txnp, TSRemapRequestInfo *rri) for (auto &op : *ops) { Dbg(dbg_ctl, ">>> processing new operation"); - bool used_sendto = false; - if (op->process(jar, rewrite_to, status, rri, req_url, used_sendto)) { + bool used_sendto = false; + std::vector regex_match_strings; + int regex_ccount = 0; + if (op->process(jar, rewrite_to, status, rri, req_url, used_sendto, regex_match_strings, regex_ccount)) { cr_substitutions(rewrite_to, req_url); size_t pos = 7; // 7 because we want to ignore the // in @@ -1268,12 +1344,101 @@ TSRemapDoRemap(void *ih, TSHttpTxn txnp, TSRemapRequestInfo *rri) TSError("can't parse substituted URL string"); goto error; } else { + bool host_header_was_set = false; + + // Set custom headers if configured and we took the sendto path. + if (!op->getSendtoHeaders().empty() && used_sendto) { + for (auto const &header_pair : op->getSendtoHeaders()) { + std::string header_name = header_pair.first; + std::string header_value = header_pair.second; + + // Apply regex substitution to header value if we have regex matches ($1, $2, etc.) + if (regex_ccount > 0 && !regex_match_strings.empty() && header_value.find('$') != std::string::npos) { + std::string::size_type pos = 0; + std::string::size_type ppos = 0; + std::string substituted_value; + substituted_value.reserve(header_value.size() * 2); + + while (pos < header_value.length()) { + pos = header_value.find('$', ppos); + if (pos == std::string::npos) { + break; + } + // Check if there's a digit after the $ + if (pos + 1 < header_value.length() && isdigit(header_value[pos + 1])) { + int const ix = header_value[pos + 1] - '0'; + if (ix <= regex_ccount && ix < static_cast(regex_match_strings.size())) { + // Append everything before the $ + substituted_value += header_value.substr(ppos, pos - ppos); + // Append the regex match string + substituted_value += regex_match_strings[ix]; + // Move past the $N + ppos = pos + 2; + } + } + pos++; + } + // Append any remaining text + if (ppos < header_value.length()) { + substituted_value += header_value.substr(ppos); + } + header_value = substituted_value; + } + + // Apply cr_substitutions for variables like $path, $cr_req_url, etc. + cr_substitutions(header_value, req_url); + + Dbg(dbg_ctl, "Setting header: %s to value: %s", header_name.c_str(), header_value.c_str()); + + // Find or create the header + TSMLoc field_loc = TSMimeHdrFieldFind(rri->requestBufp, rri->requestHdrp, header_name.c_str(), header_name.length()); + + if (field_loc == TS_NULL_MLOC) { + // Header doesn't exist, create it + if (TS_SUCCESS == TSMimeHdrFieldCreateNamed(rri->requestBufp, rri->requestHdrp, header_name.c_str(), + header_name.length(), &field_loc)) { + if (TS_SUCCESS == TSMimeHdrFieldValueStringSet(rri->requestBufp, rri->requestHdrp, field_loc, -1, + header_value.c_str(), header_value.length())) { + TSMimeHdrFieldAppend(rri->requestBufp, rri->requestHdrp, field_loc); + Dbg(dbg_ctl, "Created and set header: %s", header_name.c_str()); + } + TSHandleMLocRelease(rri->requestBufp, rri->requestHdrp, field_loc); + } + } else { + // Header exists, update it + TSMLoc tmp = TS_NULL_MLOC; + bool first = true; + + while (field_loc != TS_NULL_MLOC) { + tmp = TSMimeHdrFieldNextDup(rri->requestBufp, rri->requestHdrp, field_loc); + if (first) { + first = false; + if (TS_SUCCESS == TSMimeHdrFieldValueStringSet(rri->requestBufp, rri->requestHdrp, field_loc, -1, + header_value.c_str(), header_value.length())) { + Dbg(dbg_ctl, "Updated header: %s", header_name.c_str()); + } + } else { + // Remove duplicate headers + TSMimeHdrFieldDestroy(rri->requestBufp, rri->requestHdrp, field_loc); + } + TSHandleMLocRelease(rri->requestBufp, rri->requestHdrp, field_loc); + field_loc = tmp; + } + } + + // Check if we're setting the Host header (case-insensitive) + if (strcasecmp(header_name.c_str(), "Host") == 0) { + host_header_was_set = true; + } + } + } + // Disable pristine host header if configured to do so and we took the // sendto path. This allows the Host header to be updated to match the // remapped destination. The else path (i.e., the non-sendto one) // always preserves the pristine host header configuration, whether // enabled or disabled. - if (op->getDisablePristineHostHdr() && used_sendto) { + if (used_sendto && (op->getDisablePristineHostHdr() || host_header_was_set)) { Dbg(dbg_ctl, "Disabling pristine_host_hdr for this transaction (sendto path)"); TSHttpTxnConfigIntSet(txnp, TS_CONFIG_URL_REMAP_PRISTINE_HOST_HDR, 0); } diff --git a/tests/gold_tests/pluginTest/cookie_remap/configs/set_sendto_headers_config.yaml b/tests/gold_tests/pluginTest/cookie_remap/configs/set_sendto_headers_config.yaml new file mode 100644 index 00000000000..d72b83af3bf --- /dev/null +++ b/tests/gold_tests/pluginTest/cookie_remap/configs/set_sendto_headers_config.yaml @@ -0,0 +1,56 @@ +# 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 configuration for set_sendto_headers functionality +# Tests setting various headers including Host header + +# Test 1: Simple Host header setting with exists operation +op: + cookie: SessionID + operation: exists + sendto: http://backend.com:$BACKEND_PORT/app + set_sendto_headers: + - Host: backend.com + - X-Custom-Header: custom-value + +# Test 2: Using regex capture groups in headers +op: + cookie: UserTier + operation: regex + regex: (premium|standard) + sendto: http://$1.service.com:$SERVICE_PORT/api + set_sendto_headers: + - Host: $1.service.com + - X-User-Tier: $1 + +# Test 3: Using path variables +op: + cookie: Debug + operation: exists + sendto: http://debug.com:$DEBUG_PORT/debug + set_sendto_headers: + - X-Original-Path: $path + - X-Debug-Mode: enabled + +# Test 4: Multiple headers without Host (should preserve pristine host) +op: + cookie: NoHost + operation: exists + sendto: http://nohost.com:$NOHOST_PORT/test + set_sendto_headers: + - X-Custom-1: value1 + - X-Custom-2: value2 + diff --git a/tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.replay.yaml b/tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.replay.yaml new file mode 100644 index 00000000000..a8b32215fea --- /dev/null +++ b/tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.replay.yaml @@ -0,0 +1,177 @@ +# 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" + +sessions: + # Test 1: Simple Host header setting with exists operation + - transactions: + - client-request: + method: GET + url: /app/test + version: '1.1' + headers: + fields: + - [Host, example.com] + - [Cookie, SessionID=abc123] + - [uuid, test1-simple-host] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, '0'] + + proxy-request: + headers: + fields: + - [Host, {value: backend.com, as: prefix}] + - [X-Custom-Header, {value: custom-value, as: equal}] + + # Test 2: Using regex capture groups - premium tier + - transactions: + - client-request: + method: GET + url: /api/data + version: '1.1' + headers: + fields: + - [Host, example.com] + - [Cookie, UserTier=premium] + - [uuid, test2-regex-premium] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, '0'] + + proxy-request: + headers: + fields: + - [Host, {value: premium.service.com, as: prefix}] + - [X-User-Tier, {value: premium, as: equal}] + + # Test 3: Using regex capture groups - standard tier + - transactions: + - client-request: + method: GET + url: /api/data + version: '1.1' + headers: + fields: + - [Host, example.com] + - [Cookie, UserTier=standard] + - [uuid, test3-regex-standard] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, '0'] + + proxy-request: + headers: + fields: + - [Host, {value: standard.service.com, as: prefix}] + - [X-User-Tier, {value: standard, as: equal}] + + # Test 4: Using path variables + - transactions: + - client-request: + method: GET + url: /debug/path/to/resource + version: '1.1' + headers: + fields: + - [Host, example.com] + - [Cookie, Debug=true] + - [uuid, test4-path-vars] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, '0'] + + proxy-request: + headers: + fields: + - [X-Original-Path, {value: debug/path/to/resource, as: equal}] + - [X-Debug-Mode, {value: enabled, as: equal}] + + # Test 5: Multiple headers without Host (should preserve pristine host) + - transactions: + - client-request: + method: GET + url: /test/endpoint + version: '1.1' + headers: + fields: + - [Host, example.com] + - [Cookie, NoHost=yes] + - [uuid, test5-no-host] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, '0'] + + proxy-request: + headers: + fields: + - [Host, {value: example.com, as: equal}] + - [X-Custom-1, {value: value1, as: equal}] + - [X-Custom-2, {value: value2, as: equal}] + + # Test 6: No matching cookie - verify headers are NOT set on non-sendto path. + - transactions: + - client-request: + method: GET + url: /nomatch/endpoint + version: '1.1' + headers: + fields: + - [Host, example.com] + - [Cookie, UnknownCookie=value] + - [uuid, test6-no-match] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Length, '0'] + + proxy-request: + headers: + fields: + - [Host, {value: example.com, as: equal}] + # Verify that set_sendto_headers are NOT present on non-sendto path + - [X-Custom-Header, {as: absent}] + - [X-User-Tier, {as: absent}] + - [X-Original-Path, {as: absent}] + - [X-Debug-Mode, {as: absent}] + - [X-Custom-1, {as: absent}] + - [X-Custom-2, {as: absent}] + diff --git a/tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.test.py b/tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.test.py new file mode 100644 index 00000000000..667960e7115 --- /dev/null +++ b/tests/gold_tests/pluginTest/cookie_remap/set_sendto_headers.test.py @@ -0,0 +1,148 @@ +''' +Verify cookie_remap plugin's set_sendto_headers functionality. +''' +# 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 = ''' +Test cookie_remap plugin's set_sendto_headers functionality. Verifies that: +1. Headers can be set dynamically based on cookie rules +2. Host header setting automatically disables pristine host header +3. Regex capture groups work in header values ($1, $2, etc.) +4. Path variables work in header values ($path, etc.) +5. Multiple headers can be set simultaneously +6. Headers are ONLY set on sendto path, NOT on non-matching (else) path +''' +Test.SkipUnless(Condition.PluginExists('cookie_remap.so')) +Test.ContinueOnFail = True + + +class TestSetSendtoHeaders: + """ + Test the set_sendto_headers feature of cookie_remap plugin. + + This test verifies that: + 1. Simple header setting works (including Host) + 2. Regex capture groups are substituted correctly in header values + 3. Path variables are substituted correctly in header values + 4. Multiple headers can be set + 5. When Host header is set, pristine_host_hdr is automatically disabled + 6. When Host header is NOT set, pristine_host_hdr behavior is preserved + 7. Headers are ONLY set on sendto path (not on non-matching requests) + """ + + def __init__(self): + """Initialize the test by setting up servers and ATS configuration.""" + self.replay_file = 'set_sendto_headers.replay.yaml' + self._setupDns() + self._setupServers() + self._setupTS() + self._setupClient() + + def _setupDns(self): + """Configure the DNS server.""" + self._dns = Test.MakeDNServer("dns", default='127.0.0.1') + + def _setupServers(self): + """ + Configure the origin servers using proxy-verifier. + + Creates multiple servers to simulate different backend services. + """ + # Server for simple host header test + self._server_backend = Test.MakeVerifierServerProcess("server_backend", self.replay_file) + self._server_backend.Streams.All += Testers.ContainsExpression( + 'backend.com', 'Host header should be backend.com for test 1') + self._server_backend.Streams.All += Testers.ContainsExpression('custom-value', 'X-Custom-Header should be set for test 1') + + # Server for regex capture group tests (premium and standard) + self._server_service = Test.MakeVerifierServerProcess("server_service", self.replay_file) + self._server_service.Streams.All += Testers.ContainsExpression( + 'premium.service.com', 'Host header should be premium.service.com for premium tier') + self._server_service.Streams.All += Testers.ContainsExpression( + 'standard.service.com', 'Host header should be standard.service.com for standard tier') + + # Server for path variable test + self._server_debug = Test.MakeVerifierServerProcess("server_debug", self.replay_file) + + # Server for no-host test (pristine host preserved) + self._server_nohost = Test.MakeVerifierServerProcess("server_nohost", self.replay_file) + self._server_nohost.Streams.All += Testers.ContainsExpression( + 'example.com', 'Host header should be example.com (pristine) when Host not set in set_sendto_headers') + + # Server for non-matching cookie test (verifies headers are NOT set on non-sendto path) + self._server_nomatch = Test.MakeVerifierServerProcess("server_nomatch", self.replay_file) + self._server_nomatch.Streams.All += Testers.ExcludesExpression( + 'X-Custom-Header', 'Custom headers should NOT be present on non-sendto path') + self._server_nomatch.Streams.All += Testers.ExcludesExpression( + 'X-User-Tier', 'Custom headers should NOT be present on non-sendto path') + + def _setupTS(self): + """Configure Traffic Server with cookie_remap plugin.""" + ts = Test.MakeATSProcess("ts", enable_cache=False) + self._ts = ts + + # Enable debug logging for cookie_remap and enable pristine_host_hdr + # (simulating production environment) + ts.Disk.records_config.update( + { + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'cookie_remap|http', + 'proxy.config.dns.nameservers': f"127.0.0.1:{self._dns.Variables.Port}", + 'proxy.config.dns.resolv_conf': 'NULL', + 'proxy.config.url_remap.pristine_host_hdr': 1, + }) + + # Read and configure the cookie_remap configuration file + config_filename = 'set_sendto_headers_config.yaml' + config_path = os.path.join(Test.TestDirectory, f"configs/{config_filename}") + with open(config_path, 'r') as config_file: + config_content = config_file.read() + + # Replace port placeholders + config_content = config_content.replace("$BACKEND_PORT", str(self._server_backend.Variables.http_port)) + config_content = config_content.replace("$SERVICE_PORT", str(self._server_service.Variables.http_port)) + config_content = config_content.replace("$DEBUG_PORT", str(self._server_debug.Variables.http_port)) + config_content = config_content.replace("$NOHOST_PORT", str(self._server_nohost.Variables.http_port)) + config_content = config_content.replace("$NOMATCH_PORT", str(self._server_nomatch.Variables.http_port)) + + ts.Disk.File(ts.Variables.CONFIGDIR + f"/{config_filename}", id="cookie_config") + ts.Disk.cookie_config.WriteOn(config_content) + + # Configure remap rule with cookie_remap plugin + # The default target should point to the nomatch server for testing + ts.Disk.remap_config.AddLine( + f'map http://example.com http://127.0.0.1:{self._server_nomatch.Variables.http_port} ' + f'@plugin=cookie_remap.so @pparam=config/{config_filename}') + + def _setupClient(self): + """Setup the client for the test.""" + tr = Test.AddTestRun('Test cookie_remap set_sendto_headers functionality') + + p = tr.AddVerifierClientProcess('client', self.replay_file, http_ports=[self._ts.Variables.port]) + p.StartBefore(self._dns) + p.StartBefore(self._ts) + p.StartBefore(self._server_backend) + p.StartBefore(self._server_service) + p.StartBefore(self._server_debug) + p.StartBefore(self._server_nohost) + p.StartBefore(self._server_nomatch) + + +# Execute the test +TestSetSendtoHeaders()