Skip to content

Commit 4cf9b07

Browse files
committed
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.
1 parent 4b3354f commit 4cf9b07

File tree

5 files changed

+667
-16
lines changed

5 files changed

+667
-16
lines changed

doc/admin-guide/plugins/cookie_remap.en.rst

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Cookie Based Routing Inside TrafficServer Using cookie_remap
4242
* :ref:`else: url [optional] <else-url-optional>`
4343
* :ref:`connector: and <connector-and>`
4444
* :ref:`disable_pristine_host_hdr: true|false [optional] <disable-pristine-host-hdr>`
45+
* :ref:`set_sendto_headers: [optional] <set-sendto-headers>`
4546

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

@@ -233,6 +234,110 @@ This option only affects the successful match (``sendto``) path. The ``else``
233234
path will continue to use the configured pristine host header setting (typically
234235
enabled in production environments).
235236

237+
.. _set-sendto-headers:
238+
239+
set_sendto_headers: [optional]
240+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
241+
242+
Sets arbitrary HTTP request headers when a rule matches and takes the ``sendto``
243+
path. This provides a flexible way to modify request headers, including the
244+
Host header, for redirected requests.
245+
246+
Headers are only set when:
247+
248+
* The operation matches successfully.
249+
* The ``sendto`` path is taken (not the ``else`` path).
250+
251+
**Format:**
252+
253+
The value must be a YAML sequence (list) where each item is a single-key map
254+
representing a header name and its value:
255+
256+
.. code-block:: yaml
257+
258+
set_sendto_headers:
259+
- Header-Name: header-value
260+
- Another-Header: another-value
261+
262+
**Header Value Substitution:**
263+
264+
The special variables mentioned below for regex capture groups, path variables,
265+
URL variables, unmatched path, and URL encoding can be used in the header value.
266+
267+
* **Regex capture groups**: ``$1``, ``$2``, ... ``$9`` (from regex operations).
268+
* **Path variables**: ``$path``, ``$ppath`` (pre-remapped path).
269+
* **URL variables**: ``$cr_req_url``, ``$cr_req_purl`` (pre-remapped URL).
270+
* **Unmatched path**: ``$unmatched_path``, ``$unmatched_ppath``.
271+
* **URL encoding**: ``$cr_urlencode(...)``.
272+
273+
**Special Behavior for Host Header:**
274+
275+
When ``set_sendto_headers`` includes a ``Host`` header (case-insensitive), the
276+
pristine host header is automatically disabled for that transaction. This allows
277+
the Host header to be updated to the specified value. You do not need to also
278+
set ``disable_pristine_host_hdr: true`` in this case.
279+
280+
**Interaction with disable_pristine_host_hdr:**
281+
282+
* If ``set_sendto_headers`` sets the Host header, pristine host header is
283+
automatically disabled.
284+
* If ``set_sendto_headers`` does NOT set Host but ``disable_pristine_host_hdr``
285+
is ``true``, pristine host header is still disabled
286+
* If neither condition applies, pristine host header behavior follows the
287+
global configuration.
288+
289+
**Examples:**
290+
291+
Setting a static Host header to the bucketed destination:
292+
293+
.. code-block:: yaml
294+
295+
op:
296+
cookie: SessionID
297+
operation: exists
298+
sendto: http://backend.internal.com/app
299+
set_sendto_headers:
300+
- Host: backend.internal.com
301+
302+
Using regex capture groups in headers:
303+
304+
.. code-block:: yaml
305+
306+
op:
307+
cookie: UserSegment
308+
operation: regex
309+
regex: (premium|standard)
310+
sendto: http://$1.service.com/app
311+
set_sendto_headers:
312+
- Host: $1.service.com
313+
- X-User-Tier: $1
314+
315+
Using path variables:
316+
317+
.. code-block:: yaml
318+
319+
op:
320+
cookie: Debug
321+
operation: exists
322+
sendto: http://debug.example.com
323+
set_sendto_headers:
324+
- X-Original-Path: $path
325+
- X-Original-URL: $cr_urlencode($cr_req_url)
326+
327+
Multiple headers with bucket routing:
328+
329+
.. code-block:: yaml
330+
331+
op:
332+
cookie: SessionID
333+
operation: bucket
334+
bucket: 10/100
335+
sendto: http://canary.example.com/app/$unmatched_path
336+
set_sendto_headers:
337+
- Host: canary.example.com
338+
- X-Canary-Request: true
339+
- X-Session-Bucket: canary
340+
236341
.. _reserved-path-expressions:
237342

238343
Reserved path expressions

plugins/experimental/cookie_remap/cookie_remap.cc

Lines changed: 181 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,8 @@ class subop
453453
};
454454

455455
using SubOpQueue = std::vector<std::unique_ptr<subop>>;
456+
using HeaderPair = std::pair<std::string, std::string>;
457+
using HeaderList = std::vector<HeaderPair>;
456458

457459
//----------------------------------------------------------------------------
458460
class op
@@ -514,6 +516,18 @@ class op
514516
return disable_pristine_host_hdr;
515517
}
516518

519+
void
520+
addSendtoHeader(std::string_view const name, std::string_view const value)
521+
{
522+
sendto_headers.emplace_back(name, value);
523+
}
524+
525+
HeaderList const &
526+
getSendtoHeaders() const
527+
{
528+
return sendto_headers;
529+
}
530+
517531
void
518532
printOp() const
519533
{
@@ -530,11 +544,17 @@ class op
530544
if (disable_pristine_host_hdr) {
531545
Dbg(dbg_ctl, "disable_pristine_host_hdr: true");
532546
}
547+
if (!sendto_headers.empty()) {
548+
Dbg(dbg_ctl, "set_sendto_headers:");
549+
for (auto const &header : sendto_headers) {
550+
Dbg(dbg_ctl, " %s: %s", header.first.c_str(), header.second.c_str());
551+
}
552+
}
533553
}
534554

535555
bool
536556
process(CookieJar &jar, std::string &dest, TSHttpStatus &retstat, TSRemapRequestInfo *rri, UrlComponents &req_url,
537-
bool &used_sendto) const
557+
bool &used_sendto, std::vector<std::string> &regex_match_strings, int &regex_ccount) const
538558
{
539559
if (sendto == "") {
540560
return false; // guessing every operation must have a
@@ -686,10 +706,19 @@ class op
686706

687707
// OPERATION::regex matching
688708
if (subop_type == REGEXP) {
689-
RegexMatches matches;
690-
int ret = subop->regexMatch(string_to_match.c_str(), string_to_match.length(), matches);
709+
RegexMatches regex_matches;
710+
int ret = subop->regexMatch(string_to_match.c_str(), string_to_match.length(), regex_matches);
691711

692712
if (ret >= 0) {
713+
regex_ccount = subop->getRegexCcount(); // Store for later use in header substitution
714+
715+
regex_match_strings.clear();
716+
regex_match_strings.reserve(regex_ccount + 1);
717+
for (int i = 0; i <= regex_ccount; i++) {
718+
auto const &match = regex_matches[i];
719+
regex_match_strings.emplace_back(match.data(), match.size());
720+
}
721+
693722
std::string::size_type pos = sendto.find('$');
694723
std::string::size_type ppos = 0;
695724

@@ -717,9 +746,9 @@ class op
717746
if (isdigit(sendto[pos + 1])) {
718747
int ix = sendto[pos + 1] - '0';
719748

720-
if (ix <= subop->getRegexCcount()) { // Just skip an illegal regex group
749+
if (ix <= regex_ccount) { // Just skip an illegal regex group
721750
dest += sendto.substr(ppos, pos - ppos);
722-
auto regex_match = matches[ix];
751+
auto regex_match = regex_matches[ix];
723752
dest.append(regex_match.data(), regex_match.size());
724753
ppos = pos + 2;
725754
} else {
@@ -812,6 +841,7 @@ class op
812841
TSHttpStatus status = TS_HTTP_STATUS_NONE;
813842
TSHttpStatus else_status = TS_HTTP_STATUS_NONE;
814843
bool disable_pristine_host_hdr = false;
844+
HeaderList sendto_headers{};
815845
};
816846

817847
using StringPair = std::pair<std::string, std::string>;
@@ -854,6 +884,19 @@ build_op(op &o, OpMap const &q)
854884
o.setDisablePristineHostHdr(val == "true" || val == "1" || val == "yes");
855885
}
856886

887+
if (key == "__set_sendto_header__") {
888+
// Parse "header_name: header_value" format. We set this below in the TSRemapNewInstance function.
889+
size_t const colon_pos = val.find(": ");
890+
if (colon_pos != std::string::npos) {
891+
std::string_view const header_name = std::string_view(val).substr(0, colon_pos);
892+
std::string_view const header_value = std::string_view(val).substr(colon_pos + 2);
893+
o.addSendtoHeader(header_name, header_value);
894+
} else {
895+
Dbg(dbg_ctl, "ERROR: invalid set_sendto_header format: %s", val.c_str());
896+
goto error;
897+
}
898+
}
899+
857900
if (key == "operation") {
858901
sub->setOperation(val);
859902
}
@@ -933,16 +976,47 @@ TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSE
933976
for (YAML::const_iterator it2 = it->second.begin(); it2 != it->second.end(); ++it2) {
934977
const YAML::Node first = it2->first;
935978
const YAML::Node second = it2->second;
979+
const string &key = first.as<std::string>();
980+
981+
// Special handling for set_sendto_headers which is a sequence of maps
982+
if (key == "set_sendto_headers") {
983+
if (!second.IsSequence()) {
984+
const string reason = "set_sendto_headers must be a sequence";
985+
TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str());
986+
return TS_ERROR;
987+
}
936988

937-
if (second.IsScalar() == false) {
938-
const string reason = "All op nodes must be of type scalar";
939-
TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str());
940-
return TS_ERROR;
941-
}
989+
for (const auto &header_node : second) {
990+
if (!header_node.IsMap()) {
991+
const string reason = "Each set_sendto_headers item must be a map";
992+
TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str());
993+
return TS_ERROR;
994+
}
995+
996+
// Each header should be a single-key map
997+
if (header_node.size() != 1) {
998+
const string reason = "Each set_sendto_headers item must be a single key-value pair";
999+
TSError("Invalid YAML Configuration format for cookie_remap: %s, reason: %s", filename.c_str(), reason.c_str());
1000+
return TS_ERROR;
1001+
}
9421002

943-
const string &key = first.as<std::string>();
944-
const string &value = second.as<std::string>();
945-
op_data.emplace_back(key, value);
1003+
for (const auto &kv : header_node) {
1004+
const string &header_name = kv.first.as<std::string>();
1005+
const string &header_value = kv.second.as<std::string>();
1006+
// Store with special prefix to identify in build_op
1007+
op_data.emplace_back("__set_sendto_header__", header_name + ": " + header_value);
1008+
}
1009+
}
1010+
} else {
1011+
if (second.IsScalar() == false) {
1012+
TSError("Invalid YAML Configuration format for cookie_remap: %s, non-scalar value for key: %s (type=%d)",
1013+
filename.c_str(), key.c_str(), second.Type());
1014+
return TS_ERROR;
1015+
}
1016+
1017+
const string &value = second.as<std::string>();
1018+
op_data.emplace_back(key, value);
1019+
}
9461020
}
9471021

9481022
if (op_data.size()) {
@@ -1206,8 +1280,10 @@ TSRemapDoRemap(void *ih, TSHttpTxn txnp, TSRemapRequestInfo *rri)
12061280

12071281
for (auto &op : *ops) {
12081282
Dbg(dbg_ctl, ">>> processing new operation");
1209-
bool used_sendto = false;
1210-
if (op->process(jar, rewrite_to, status, rri, req_url, used_sendto)) {
1283+
bool used_sendto = false;
1284+
std::vector<std::string> regex_match_strings;
1285+
int regex_ccount = 0;
1286+
if (op->process(jar, rewrite_to, status, rri, req_url, used_sendto, regex_match_strings, regex_ccount)) {
12111287
cr_substitutions(rewrite_to, req_url);
12121288

12131289
size_t pos = 7; // 7 because we want to ignore the // in
@@ -1268,12 +1344,101 @@ TSRemapDoRemap(void *ih, TSHttpTxn txnp, TSRemapRequestInfo *rri)
12681344
TSError("can't parse substituted URL string");
12691345
goto error;
12701346
} else {
1347+
bool host_header_was_set = false;
1348+
1349+
// Set custom headers if configured and we took the sendto path.
1350+
if (!op->getSendtoHeaders().empty() && used_sendto) {
1351+
for (auto const &header_pair : op->getSendtoHeaders()) {
1352+
std::string header_name = header_pair.first;
1353+
std::string header_value = header_pair.second;
1354+
1355+
// Apply regex substitution to header value if we have regex matches ($1, $2, etc.)
1356+
if (regex_ccount > 0 && !regex_match_strings.empty() && header_value.find('$') != std::string::npos) {
1357+
std::string::size_type pos = 0;
1358+
std::string::size_type ppos = 0;
1359+
std::string substituted_value;
1360+
substituted_value.reserve(header_value.size() * 2);
1361+
1362+
while (pos < header_value.length()) {
1363+
pos = header_value.find('$', ppos);
1364+
if (pos == std::string::npos) {
1365+
break;
1366+
}
1367+
// Check if there's a digit after the $
1368+
if (pos + 1 < header_value.length() && isdigit(header_value[pos + 1])) {
1369+
int const ix = header_value[pos + 1] - '0';
1370+
if (ix <= regex_ccount && ix < static_cast<int>(regex_match_strings.size())) {
1371+
// Append everything before the $
1372+
substituted_value += header_value.substr(ppos, pos - ppos);
1373+
// Append the regex match string
1374+
substituted_value += regex_match_strings[ix];
1375+
// Move past the $N
1376+
ppos = pos + 2;
1377+
}
1378+
}
1379+
pos++;
1380+
}
1381+
// Append any remaining text
1382+
if (ppos < header_value.length()) {
1383+
substituted_value += header_value.substr(ppos);
1384+
}
1385+
header_value = substituted_value;
1386+
}
1387+
1388+
// Apply cr_substitutions for variables like $path, $cr_req_url, etc.
1389+
cr_substitutions(header_value, req_url);
1390+
1391+
Dbg(dbg_ctl, "Setting header: %s to value: %s", header_name.c_str(), header_value.c_str());
1392+
1393+
// Find or create the header
1394+
TSMLoc field_loc = TSMimeHdrFieldFind(rri->requestBufp, rri->requestHdrp, header_name.c_str(), header_name.length());
1395+
1396+
if (field_loc == TS_NULL_MLOC) {
1397+
// Header doesn't exist, create it
1398+
if (TS_SUCCESS == TSMimeHdrFieldCreateNamed(rri->requestBufp, rri->requestHdrp, header_name.c_str(),
1399+
header_name.length(), &field_loc)) {
1400+
if (TS_SUCCESS == TSMimeHdrFieldValueStringSet(rri->requestBufp, rri->requestHdrp, field_loc, -1,
1401+
header_value.c_str(), header_value.length())) {
1402+
TSMimeHdrFieldAppend(rri->requestBufp, rri->requestHdrp, field_loc);
1403+
Dbg(dbg_ctl, "Created and set header: %s", header_name.c_str());
1404+
}
1405+
TSHandleMLocRelease(rri->requestBufp, rri->requestHdrp, field_loc);
1406+
}
1407+
} else {
1408+
// Header exists, update it
1409+
TSMLoc tmp = TS_NULL_MLOC;
1410+
bool first = true;
1411+
1412+
while (field_loc != TS_NULL_MLOC) {
1413+
tmp = TSMimeHdrFieldNextDup(rri->requestBufp, rri->requestHdrp, field_loc);
1414+
if (first) {
1415+
first = false;
1416+
if (TS_SUCCESS == TSMimeHdrFieldValueStringSet(rri->requestBufp, rri->requestHdrp, field_loc, -1,
1417+
header_value.c_str(), header_value.length())) {
1418+
Dbg(dbg_ctl, "Updated header: %s", header_name.c_str());
1419+
}
1420+
} else {
1421+
// Remove duplicate headers
1422+
TSMimeHdrFieldDestroy(rri->requestBufp, rri->requestHdrp, field_loc);
1423+
}
1424+
TSHandleMLocRelease(rri->requestBufp, rri->requestHdrp, field_loc);
1425+
field_loc = tmp;
1426+
}
1427+
}
1428+
1429+
// Check if we're setting the Host header (case-insensitive)
1430+
if (strcasecmp(header_name.c_str(), "Host") == 0) {
1431+
host_header_was_set = true;
1432+
}
1433+
}
1434+
}
1435+
12711436
// Disable pristine host header if configured to do so and we took the
12721437
// sendto path. This allows the Host header to be updated to match the
12731438
// remapped destination. The else path (i.e., the non-sendto one)
12741439
// always preserves the pristine host header configuration, whether
12751440
// enabled or disabled.
1276-
if (op->getDisablePristineHostHdr() && used_sendto) {
1441+
if (used_sendto && (op->getDisablePristineHostHdr() || host_header_was_set)) {
12771442
Dbg(dbg_ctl, "Disabling pristine_host_hdr for this transaction (sendto path)");
12781443
TSHttpTxnConfigIntSet(txnp, TS_CONFIG_URL_REMAP_PRISTINE_HOST_HDR, 0);
12791444
}

0 commit comments

Comments
 (0)