Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions doc/admin-guide/plugins/cookie_remap.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Cookie Based Routing Inside TrafficServer Using cookie_remap
* :ref:`else: url [optional] <else-url-optional>`
* :ref:`connector: and <connector-and>`
* :ref:`disable_pristine_host_hdr: true|false [optional] <disable-pristine-host-hdr>`
* :ref:`set_sendto_headers: [optional] <set-sendto-headers>`

* :ref:`Reserved path expressions <reserved-path-expressions>`

Expand Down Expand Up @@ -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
Expand Down
197 changes: 181 additions & 16 deletions plugins/experimental/cookie_remap/cookie_remap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ class subop
};

using SubOpQueue = std::vector<std::unique_ptr<subop>>;
using HeaderPair = std::pair<std::string, std::string>;
using HeaderList = std::vector<HeaderPair>;

//----------------------------------------------------------------------------
class op
Expand Down Expand Up @@ -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
{
Expand All @@ -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<std::string> &regex_match_strings, int &regex_ccount) const
{
if (sendto == "") {
return false; // guessing every operation must have a
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<std::string, std::string>;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<std::string>();

// 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<std::string>();
const string &value = second.as<std::string>();
op_data.emplace_back(key, value);
for (const auto &kv : header_node) {
const string &header_name = kv.first.as<std::string>();
const string &header_value = kv.second.as<std::string>();
// 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<std::string>();
op_data.emplace_back(key, value);
}
}

if (op_data.size()) {
Expand Down Expand Up @@ -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<std::string> 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
Expand Down Expand Up @@ -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<int>(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);
}
Expand Down
Loading