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()