diff --git a/doc/admin-guide/configuration/cache-basics.en.rst b/doc/admin-guide/configuration/cache-basics.en.rst index b3b71898179..b03d2bdae00 100644 --- a/doc/admin-guide/configuration/cache-basics.en.rst +++ b/doc/admin-guide/configuration/cache-basics.en.rst @@ -234,6 +234,84 @@ Traffic Server applies ``Cache-Control`` servability criteria after HTTP freshness criteria. For example, an object might be considered fresh but will not be served if its age is greater than its ``max-age``. +Targeted Cache Control (RFC 9213) +---------------------------------- + +Traffic Server supports `RFC 9213 `_ +Targeted HTTP Cache Control, which allows origin servers to provide different +cache directives for different classes of caches. This is particularly useful in CDN deployments where you want to +give different caching instructions to CDN caches versus browser caches. + +For example, an origin server might send:: + + Cache-Control: max-age=60 + CDN-Cache-Control: max-age=3600 + +When targeted cache control is enabled (via +:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers`), Traffic +Server will use the ``CDN-Cache-Control`` directives instead of the standard +``Cache-Control`` directives for caching decisions. The browser receiving the +response will see both headers and use the standard ``Cache-Control``, allowing +the object to be cached for 60 seconds in the browser but 3600 seconds in the CDN. + +Configuration +~~~~~~~~~~~~~ + +To enable targeted cache control, set +:ts:cv:`proxy.config.http.cache.targeted_cache_control_headers` to a +comma-separated list of header names to check in priority order:: + + # In records.yaml: + proxy.config.http.cache.targeted_cache_control_headers: CDN-Cache-Control + +Or with multiple targeted headers in priority order:: + + proxy.config.http.cache.targeted_cache_control_headers: ATS-Cache-Control,CDN-Cache-Control + +This configuration is overridable per-remap, allowing different rules for +different origins:: + + # In remap.config: + map / https://origin.example.com/ @plugin=conf_remap.so \ + @pparam=proxy.config.http.cache.targeted_cache_control_headers=CDN-Cache-Control + +Behavior +~~~~~~~~ + +- When a targeted header is found (first match in the priority list), its + directives replace the standard ``Cache-Control`` directives for all caching + decisions. + +- If no targeted headers are present or they are all empty, Traffic Server falls + back to the standard ``Cache-Control`` header. + +- Targeted headers are passed through to downstream caches, allowing CDN chains + to use the same directives. + +- All standard cache control directives are supported in targeted headers: + ``max-age``, ``s-maxage``, ``no-cache``, ``no-store``, ``private``, + ``must-revalidate``, etc. + +Use Cases +~~~~~~~~~ + +**CDN with origin cache**: An origin might have its own caching layer but want +CDNs to cache more aggressively:: + + Cache-Control: max-age=60 + CDN-Cache-Control: max-age=86400 + +**Different CDN policies**: Using multiple CDN providers with different needs:: + + Cache-Control: max-age=300 + CDN1-Cache-Control: max-age=3600 + CDN2-Cache-Control: max-age=1800 + +**Prevent CDN caching while allowing browser caching**:: + + Cache-Control: max-age=300 + CDN-Cache-Control: no-store + Revalidating HTTP Objects ------------------------- diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 360cfc5fc0d..eed7997261f 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -2517,6 +2517,31 @@ Cache Control ``Cache-Control: max-age``. ===== ====================================================================== +.. ts:cv:: CONFIG proxy.config.http.cache.targeted_cache_control_headers STRING "" + :reloadable: + :overridable: + + Comma-separated list of targeted cache control header names to check in priority + order before falling back to the standard ``Cache-Control`` header. This implements + `RFC 9213 `_ Targeted HTTP Cache Control. + When empty (the default), targeted cache control is disabled and only the standard + ``Cache-Control`` header is used. + + Example values: + + - ``CDN-Cache-Control`` - Use only CDN-Cache-Control if present + - ``ATS-Cache-Control,CDN-Cache-Control`` - Check ATS-Cache-Control first, then + CDN-Cache-Control, then fall back to Cache-Control + + When a targeted header is found, its directives are used rather than those in the + standard ``Cache-Control`` header for caching decisions. The targeted headers are + passed through to downstream caches. + + .. note:: + + This implementation uses the existing Cache-Control parser rather than the + strict RFC 8941 Structured Fields parser specified in RFC 9213. + .. ts:cv:: CONFIG proxy.config.http.cache.max_stale_age INT 604800 :reloadable: :overridable: diff --git a/doc/admin-guide/plugins/lua.en.rst b/doc/admin-guide/plugins/lua.en.rst index b00999633e3..69f8d3b5054 100644 --- a/doc/admin-guide/plugins/lua.en.rst +++ b/doc/admin-guide/plugins/lua.en.rst @@ -4396,6 +4396,7 @@ Http config constants TS_LUA_CONFIG_NET_SOCK_NOTSENT_LOWAT TS_LUA_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE TS_LUA_CONFIG_HTTP_CACHE_POST_METHOD + TS_LUA_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS TS_LUA_CONFIG_LAST_ENTRY :ref:`TOP ` diff --git a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst index 73865be169a..9a175f4e15f 100644 --- a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst +++ b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst @@ -196,6 +196,7 @@ TSOverridableConfigKey Value Config :enumerator:`TS_CONFIG_NET_SOCK_NOTSENT_LOWAT` :ts:cv:`proxy.config.net.sock_notsent_lowat` :enumerator:`TS_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE` :ts:cv:`proxy.config.body_factory.response_suppression_mode` :enumerator:`TS_CONFIG_HTTP_CACHE_POST_METHOD` :ts:cv:`proxy.config.http.cache.post_method` +:enumerator:`TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS` :ts:cv:`proxy.config.http.cache.targeted_cache_control_headers` ====================================================================== ==================================================================== Examples diff --git a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst index 64c4b19571e..56d325e6192 100644 --- a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst +++ b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst @@ -163,6 +163,7 @@ Enumeration Members .. enumerator:: TS_CONFIG_HTTP_NO_DNS_JUST_FORWARD_TO_PARENT .. enumerator:: TS_CONFIG_HTTP_CACHE_IGNORE_QUERY .. enumerator:: TS_CONFIG_HTTP_CACHE_POST_METHOD +.. enumerator:: TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS Description diff --git a/include/proxy/hdrs/MIME.h b/include/proxy/hdrs/MIME.h index 4bed80dc13c..555f540872c 100644 --- a/include/proxy/hdrs/MIME.h +++ b/include/proxy/hdrs/MIME.h @@ -24,6 +24,7 @@ #pragma once #include +#include #include #include @@ -325,7 +326,7 @@ struct MIMEHdrImpl : public HdrHeapObjImpl { void check_strings(HeapCheck *heaps, int num_heaps); // Cooked values - void recompute_cooked_stuff(MIMEField *changing_field_or_null = nullptr); + void recompute_cooked_stuff(MIMEField *changing_field_or_null = nullptr, std::span targeted_headers = {}); void recompute_accelerators_and_presence_bits(); // Utility diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h index 85cb7ea433c..8580fa3563c 100644 --- a/include/proxy/http/HttpConfig.h +++ b/include/proxy/http/HttpConfig.h @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -92,6 +93,37 @@ class HttpStatusCodeList HttpStatusBitset _data; }; +/** + * Pre-parsed list of targeted cache control header names (RFC 9213). + * + * Instead of parsing a comma-separated string on each request, this class + * stores the header names as an array of string_views into a stable backing + * string. The Converter ensures the string is parsed once at config load time + * and whenever the per-transaction override is set. + */ +class TargetedCacheControlHeaders +{ +public: + static const MgmtConverter Conv; + + /// Maximum number of targeted headers supported. + static constexpr size_t MAX_HEADERS = 8; + + char *conf_value{nullptr}; + std::string_view headers[MAX_HEADERS]; + size_t count{0}; + + /// Parse a comma-separated header list into the headers array. + void parse(std::string_view src); + + /// Return a span of the parsed headers. + std::span + get_headers() const + { + return std::span{headers, count}; + } +}; + struct HttpStatsBlock { // Need two stats for these for counts and times Metrics::Counter::AtomicType *background_fill_bytes_aborted; @@ -550,6 +582,8 @@ struct OverridableHttpConfigParams { MgmtByte cache_range_write = 0; MgmtByte allow_multi_range = 0; + TargetedCacheControlHeaders targeted_cache_control_headers; + MgmtByte ignore_accept_mismatch = 0; MgmtByte ignore_accept_language_mismatch = 0; MgmtByte ignore_accept_encoding_mismatch = 0; @@ -884,7 +918,9 @@ class ParsedConfigCache */ struct ParsedValue { std::string conf_value_storage{}; // Owns the string data. - std::variant parsed{}; + std::variant + parsed{}; }; /** Return the parsed value for the configuration. @@ -985,6 +1021,7 @@ inline HttpConfigParams::~HttpConfigParams() ats_free(oride.host_res_data.conf_value); ats_free(oride.negative_caching_list.conf_value); ats_free(oride.negative_revalidating_list.conf_value); + ats_free(oride.targeted_cache_control_headers.conf_value); delete connect_ports; delete redirect_actions_map; diff --git a/include/proxy/http/OverridableConfigDefs.h b/include/proxy/http/OverridableConfigDefs.h index 52440b40b19..d70c4c54caa 100644 --- a/include/proxy/http/OverridableConfigDefs.h +++ b/include/proxy/http/OverridableConfigDefs.h @@ -249,6 +249,7 @@ X(HTTP_NEGATIVE_CACHING_LIST, negative_caching_list, "proxy.config.http.negative_caching_list", STRING, HttpStatusCodeList_Conv) \ X(HTTP_CONNECT_ATTEMPTS_RETRY_BACKOFF_BASE, connect_attempts_retry_backoff_base, "proxy.config.http.connect_attempts_retry_backoff_base", INT, GENERIC) \ X(HTTP_NEGATIVE_REVALIDATING_LIST, negative_revalidating_list, "proxy.config.http.negative_revalidating_list", STRING, HttpStatusCodeList_Conv) \ - X(HTTP_CACHE_POST_METHOD, cache_post_method, "proxy.config.http.cache.post_method", INT, GENERIC) + X(HTTP_CACHE_POST_METHOD, cache_post_method, "proxy.config.http.cache.post_method", INT, GENERIC) \ + X(HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS, targeted_cache_control_headers, "proxy.config.http.cache.targeted_cache_control_headers", STRING, TargetedCacheControlHeaders_Conv) // clang-format on diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in index 078ce0eb69c..5515aa7665e 100644 --- a/include/ts/apidefs.h.in +++ b/include/ts/apidefs.h.in @@ -908,6 +908,7 @@ enum TSOverridableConfigKey { TS_CONFIG_HTTP_CONNECT_ATTEMPTS_RETRY_BACKOFF_BASE, TS_CONFIG_HTTP_NEGATIVE_REVALIDATING_LIST, TS_CONFIG_HTTP_CACHE_POST_METHOD, + TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS, TS_CONFIG_LAST_ENTRY, }; diff --git a/src/api/InkAPI.cc b/src/api/InkAPI.cc index 1353be4f770..b0054b2017c 100644 --- a/src/api/InkAPI.cc +++ b/src/api/InkAPI.cc @@ -7290,6 +7290,10 @@ _memberp_to_generic(MgmtFloat *ptr, MgmtConverter const *&conv) -> typename std: #define _CONF_CASE_HttpTransact_HOST_RES_CONV(KEY, MEMBER) \ case TS_CONFIG_##KEY: ret = &overridableHttpConfig->MEMBER; conv = &HttpTransact::HOST_RES_CONV; break; +// Custom converter: Parses/formats targeted cache control header lists. +#define _CONF_CASE_TargetedCacheControlHeaders_Conv(KEY, MEMBER) \ + case TS_CONFIG_##KEY: ret = &overridableHttpConfig->MEMBER; conv = &TargetedCacheControlHeaders::Conv; break; + // Dispatcher: Routes to _CONF_CASE_ based on the CONV parameter. #define _CONF_CASE_DISPATCH(KEY, MEMBER, RECORD_NAME, DATA_TYPE, CONV) _CONF_CASE_##CONV(KEY, MEMBER) @@ -7340,6 +7344,7 @@ _conf_to_memberp(TSOverridableConfigKey conf, OverridableHttpConfigParams *overr #undef _CONF_CASE_ConnectionTracker_MAX_SERVER_CONV #undef _CONF_CASE_ConnectionTracker_SERVER_MATCH_CONV #undef _CONF_CASE_HttpTransact_HOST_RES_CONV +#undef _CONF_CASE_TargetedCacheControlHeaders_Conv #undef _CONF_CASE_DISPATCH // 2nd little helper function to find the struct member for getting. @@ -7553,6 +7558,14 @@ TSHttpTxnConfigStringSet(TSHttpTxn txnp, TSOverridableConfigKey conf, const char s->t_state.my_txn_conf().host_res_data = std::get(parsed.parsed); } break; + case TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS: + if (value && length > 0) { + auto &parsed = ParsedConfigCache::lookup(conf, std::string_view(value, length)); + s->t_state.my_txn_conf().targeted_cache_control_headers = std::get(parsed.parsed); + } else { + s->t_state.my_txn_conf().targeted_cache_control_headers = TargetedCacheControlHeaders{}; + } + break; default: { if (value && length > 0) { return _eval_conv(&(s->t_state.my_txn_conf()), conf, value, length); diff --git a/src/proxy/hdrs/HdrToken.cc b/src/proxy/hdrs/HdrToken.cc index 6e5d44cf116..712a5ed7f73 100644 --- a/src/proxy/hdrs/HdrToken.cc +++ b/src/proxy/hdrs/HdrToken.cc @@ -125,7 +125,10 @@ const char *const _hdrtoken_strs[] = { "br", // RFC-8878 - "zstd"}; + "zstd", + + // RFC-9213 Targeted Cache Control + "CDN-Cache-Control"}; HdrTokenTypeBinding _hdrtoken_strs_type_initializers[] = { {"file", HdrTokenType::SCHEME }, @@ -267,6 +270,7 @@ HdrTokenFieldInfo _hdrtoken_strs_field_initializers[] = { {"Forwarded", MIME_SLOTID_NONE, MIME_PRESENCE_NONE, (HdrTokenInfoFlags::COMMAS | HdrTokenInfoFlags::MULTVALS) }, {"Sec-WebSocket-Key", MIME_SLOTID_NONE, MIME_PRESENCE_NONE, HdrTokenInfoFlags::NONE }, {"Sec-WebSocket-Version", MIME_SLOTID_NONE, MIME_PRESENCE_NONE, HdrTokenInfoFlags::NONE }, + {"CDN-Cache-Control", MIME_SLOTID_NONE, MIME_PRESENCE_NONE, (HdrTokenInfoFlags::COMMAS | HdrTokenInfoFlags::MULTVALS) }, {nullptr, 0, 0, HdrTokenInfoFlags::NONE }, }; diff --git a/src/proxy/hdrs/MIME.cc b/src/proxy/hdrs/MIME.cc index 8eae5d78529..0b294890f5f 100644 --- a/src/proxy/hdrs/MIME.cc +++ b/src/proxy/hdrs/MIME.cc @@ -3713,7 +3713,7 @@ MIMEHdrImpl::recompute_accelerators_and_presence_bits() //////////////////////////////////////////////////////// void -MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null) +MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null, std::span targeted_headers) { int len, tlen; const char *s; @@ -3725,13 +3725,26 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null) mime_hdr_cooked_stuff_init(this, changing_field_or_null); - ////////////////////////////////////////////////// - // (1) cook the Cache-Control header if present // - ////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////// + // (1) cook the Cache-Control header (or targeted variant) if present // + ///////////////////////////////////////////////////////////////////////////// // to be safe, recompute unless you know this call is for other cooked field if ((changing_field_or_null == nullptr) || (changing_field_or_null->m_wks_idx != MIME_WKSIDX_PRAGMA)) { - field = mime_hdr_field_find(this, static_cast(MIME_FIELD_CACHE_CONTROL)); + field = nullptr; + + // Check for targeted cache control headers first (in priority order). + for (const auto &header_name : targeted_headers) { + field = mime_hdr_field_find(this, header_name); + if (field) { + break; + } + } + + // If no targeted header was found, fall back to standard Cache-Control. + if (!field) { + field = mime_hdr_field_find(this, static_cast(MIME_FIELD_CACHE_CONTROL)); + } if (field) { // try pathpaths first -- unlike most other fastpaths, this one diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc index 9276abdaefa..423351a19bc 100644 --- a/src/proxy/http/HttpConfig.cc +++ b/src/proxy/http/HttpConfig.cc @@ -694,6 +694,40 @@ const MgmtConverter HttpStatusCodeList::Conv{ }}; // clang-format on +///////////////////////////////////////////////////////////// +// +// TargetedCacheControlHeaders implementation +// +///////////////////////////////////////////////////////////// + +void +TargetedCacheControlHeaders::parse(std::string_view src) +{ + count = 0; + swoc::TextView config_view{src}; + + while (config_view && count < MAX_HEADERS) { + swoc::TextView header_name = config_view.take_prefix_at(',').trim_if(&isspace); + if (!header_name.empty()) { + headers[count++] = std::string_view{header_name.data(), header_name.size()}; + } + } +} + +// clang-format off +const MgmtConverter TargetedCacheControlHeaders::Conv{ + [](const void *data) -> std::string_view { + const TargetedCacheControlHeaders *hdrs = static_cast(data); + return hdrs->conf_value ? hdrs->conf_value : ""; + }, + [](void *data, std::string_view src) -> void { + TargetedCacheControlHeaders *hdrs = static_cast(data); + // The string_views in headers[] point into conf_value, so conf_value must + // remain stable for the lifetime of the parsed headers. + hdrs->parse(src); + }}; +// clang-format on + ///////////////////////////////////////////////////////////// // // ParsedConfigCache implementation @@ -783,6 +817,14 @@ ParsedConfigCache::parse(TSOverridableConfigKey key, std::string_view value) break; } + case TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS: { + TargetedCacheControlHeaders targeted_headers{}; + targeted_headers.conf_value = const_cast(result.conf_value_storage.data()); + targeted_headers.parse(result.conf_value_storage); + result.parsed = targeted_headers; + break; + } + default: // No special parsing needed for this config. break; @@ -1092,6 +1134,11 @@ HttpConfig::startup() HttpEstablishStaticConfigByte(c.oride.cache_required_headers, "proxy.config.http.cache.required_headers"); HttpEstablishStaticConfigByte(c.oride.cache_range_lookup, "proxy.config.http.cache.range.lookup"); HttpEstablishStaticConfigByte(c.oride.cache_range_write, "proxy.config.http.cache.range.write"); + HttpEstablishStaticConfigStringAlloc(c.oride.targeted_cache_control_headers.conf_value, + "proxy.config.http.cache.targeted_cache_control_headers"); + if (c.oride.targeted_cache_control_headers.conf_value) { + c.oride.targeted_cache_control_headers.parse(c.oride.targeted_cache_control_headers.conf_value); + } HttpEstablishStaticConfigStringAlloc(c.connect_ports_string, "proxy.config.http.connect_ports"); @@ -1397,10 +1444,14 @@ HttpConfig::reconfigure() params->max_payload_iobuf_index = m_master.max_payload_iobuf_index; params->max_msg_iobuf_index = m_master.max_msg_iobuf_index; - params->oride.cache_required_headers = m_master.oride.cache_required_headers; - params->oride.cache_range_lookup = INT_TO_BOOL(m_master.oride.cache_range_lookup); - params->oride.cache_range_write = INT_TO_BOOL(m_master.oride.cache_range_write); - params->oride.allow_multi_range = m_master.oride.allow_multi_range; + params->oride.cache_required_headers = m_master.oride.cache_required_headers; + params->oride.cache_range_lookup = INT_TO_BOOL(m_master.oride.cache_range_lookup); + params->oride.cache_range_write = INT_TO_BOOL(m_master.oride.cache_range_write); + params->oride.targeted_cache_control_headers.conf_value = ats_strdup(m_master.oride.targeted_cache_control_headers.conf_value); + if (params->oride.targeted_cache_control_headers.conf_value) { + params->oride.targeted_cache_control_headers.parse(params->oride.targeted_cache_control_headers.conf_value); + } + params->oride.allow_multi_range = m_master.oride.allow_multi_range; params->connect_ports_string = ats_strdup(m_master.connect_ports_string); params->connect_ports = parse_ports_list(params->connect_ports_string); diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 8b2b4477b6d..8d6f1a29687 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -2055,14 +2055,17 @@ HttpSM::state_read_server_response_header(int event, void *data) } // fallthrough - case ParseResult::DONE: - + case ParseResult::DONE: { if (!t_state.hdr_info.server_response.check_hdr_implements()) { t_state.http_return_code = HTTPStatus::BAD_GATEWAY; call_transact_and_set_next_state(HttpTransact::BadRequest); break; } + // Recompute cooked cache control with targeted headers. + t_state.hdr_info.server_response.m_mime->recompute_cooked_stuff(nullptr, + t_state.txn_conf->targeted_cache_control_headers.get_headers()); + SMDbg(dbg_ctl_http_seq, "Done parsing server response header"); // Now that we know that we have all of the origin server @@ -2093,6 +2096,7 @@ HttpSM::state_read_server_response_header(int event, void *data) server_entry->read_vio->disable(); // Disable the read until we finish the tunnel } break; + } case ParseResult::CONT: ink_assert(server_entry->eos == false); server_entry->read_vio->reenable(); diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc index 7ca85a7553a..b5d6b8e6d4f 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -640,6 +640,8 @@ static constexpr RecordElement RecordsConfig[] = , {RECT_CONFIG, "proxy.config.http.cache.range.write", RECD_INT, "0", RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , + {RECT_CONFIG, "proxy.config.http.cache.targeted_cache_control_headers", RECD_STRING, "", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} + , // ######################## // # heuristic expiration # diff --git a/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml b/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml new file mode 100644 index 00000000000..90300f79e30 --- /dev/null +++ b/tests/gold_tests/cache/replay/targeted-cache-control.replay.yaml @@ -0,0 +1,389 @@ +# 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 targeted cache control headers per RFC 9213 +# Test that CDN-Cache-Control overrides Cache-Control when configured + +meta: + version: "1.0" + +# Configuration section for autest integration +autest: + description: 'Test targeted cache control headers per RFC 9213' + + dns: + name: 'dns' + + server: + name: 'server' + + client: + name: 'client' + + ats: + name: 'ts' + enable_cache: true + + records_config: + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: 'http|cache' + proxy.config.http.cache.http: 1 + proxy.config.http.cache.required_headers: 0 + proxy.config.http.cache.targeted_cache_control_headers: 'ATS-Cache-Control,CDN-Cache-Control' + + remap_config: + - from: "http://example.com/" + to: "http://backend.example.com:{SERVER_HTTP_PORT}/" + + - from: "http://acme.com/" + to: "http://backend.acme.com:{SERVER_HTTP_PORT}/" + plugins: + - name: "conf_remap.so" + args: + - "proxy.config.http.cache.targeted_cache_control_headers=ACME-Cache-Control" + + +sessions: +- transactions: + + ############################################################################# + # Test 1: CDN-Cache-Control with higher max-age overrides Cache-Control + ############################################################################# + - client-request: + method: GET + url: /targeted/test1 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test1-request1] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "14"] + - [Cache-Control, "max-age=1"] + - [CDN-Cache-Control, "max-age=30"] # Should be used. + - [Connection, close] + + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "max-age=1", as: equal } ] + - [ CDN-Cache-Control, { value: "max-age=30", as: equal } ] + + ############################################################################# + # Test 2: Priority order - first targeted header wins (verify lowercase). + ############################################################################# + - client-request: + method: GET + url: /targeted/test2 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test2-request1] + + # Test lowercase *cache-control. + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "14"] + - [cache-control, "no-store"] + - [cdn-cache-control, "no-store"] + - [ats-cache-control, "max-age=30"] # Should take precedence. Test lowercase. + - [Connection, close] + + proxy-response: + status: 200 + headers: + fields: + - [ cache-control, { value: "no-store", as: equal } ] + - [ cdn-cache-control, { value: "no-store", as: equal } ] + - [ ats-cache-control, { value: "max-age=30", as: equal } ] + + + ############################################################################# + # Test 3: no-store in targeted header + ############################################################################# + - client-request: + method: GET + url: /targeted/test3 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test3-request1] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "14"] + - [Cache-Control, "max-age=3600"] + - [CDN-Cache-Control, "no-store"] # Should be used. + - [Connection, close] + + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "max-age=3600", as: equal } ] + - [ CDN-Cache-Control, { value: "no-store", as: equal } ] + + ############################################################################# + # Test 4: Vanilla Cache-Control should still function. + ############################################################################# + - client-request: + method: GET + url: /targeted/test4 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test4-request1] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "14"] + - [Cache-Control, "max-age=30"] + - [Connection, close] + + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "max-age=30", as: equal } ] + + + ############################################################################# + # Test 5: conf_remap.so override with ACME-Cache-Control + ############################################################################# + - client-request: + method: GET + url: /acme/test1 + version: '1.1' + headers: + fields: + - [Host, acme.com] + - [uuid, acme-test1-request1] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "14"] + - [Cache-Control, "no-store"] + - [CDN-Cache-Control, "no-store"] + - [ATS-Cache-Control, "no-store"] + - [ACME-Cache-Control, "max-age=30"] # Should be used due to override + - [Connection, close] + + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "no-store", as: equal } ] + - [ CDN-Cache-Control, { value: "no-store", as: equal } ] + - [ ATS-Cache-Control, { value: "no-store", as: equal } ] + - [ ACME-Cache-Control, { value: "max-age=30", as: equal } ] + + ############################################################################# + # Test 6: Verify ACME-Cache-Control override works (no-cache) + ############################################################################# + - client-request: + method: GET + url: /acme/test2 + version: '1.1' + headers: + fields: + - [Host, acme.com] + - [uuid, acme-test2-request1] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "14"] + - [Cache-Control, "max-age=3600"] + - [ACME-Cache-Control, "no-store"] # Should prevent caching + - [Connection, close] + + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "max-age=3600", as: equal } ] + - [ ACME-Cache-Control, { value: "no-store", as: equal } ] + + ############################################################################# + # Now verify the correct cache behavior from above. + ############################################################################# + - client-request: + # Delay to exceed Cache-Control but not CDN-Cache-Control. + delay: 2s + + method: GET + url: /targeted/test1 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test1-request2] + + # Should not reach the origin. + server-response: + status: 404 + reason: Not Found + + # Expect the cached 200 response. + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "max-age=1", as: equal } ] + - [ CDN-Cache-Control, { value: "max-age=30", as: equal } ] + + - client-request: + method: GET + url: /targeted/test2 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test2-request2] + + # Should not reach the origin. + server-response: + status: 404 + reason: Not Found + + # Expect the cached 200 response since ATS-Cache-Control takes precedence + # over CDN-Cache-Control and Cache-Control. + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "no-store", as: equal } ] + - [ CDN-Cache-Control, { value: "no-store", as: equal } ] + - [ ATS-Cache-Control, { value: "max-age=30", as: equal } ] + + - client-request: + method: GET + url: /targeted/test3 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test3-request2] + + # Since CDN-Cache-Control is no-store, the request should reach the origin. + server-response: + status: 404 + reason: Not Found + + proxy-response: + status: 404 + headers: + fields: + - [ Cache-Control, { as: absent } ] + - [ CDN-Cache-Control, { as: absent } ] + + - client-request: + method: GET + url: /targeted/test4 + version: '1.1' + headers: + fields: + - [Host, example.com] + - [uuid, targeted-test4-request2] + + # Should not reach the origin. + server-response: + status: 404 + reason: Not Found + + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "max-age=30", as: equal } ] + + # Verify that ACME-Cache-Control: max-age=30 allowed caching. + - client-request: + method: GET + url: /acme/test1 + version: '1.1' + headers: + fields: + - [Host, acme.com] + - [uuid, acme-test1-request2] + + # The origin should not receive the request since ATS will reply out of cache. + server-response: + status: 404 + reason: Not Found + + # Expect the cached 200 response. + proxy-response: + status: 200 + headers: + fields: + - [ Cache-Control, { value: "no-store", as: equal } ] + - [ CDN-Cache-Control, { value: "no-store", as: equal } ] + - [ ATS-Cache-Control, { value: "no-store", as: equal } ] + - [ ACME-Cache-Control, { value: "max-age=30", as: equal } ] + + # On the other hand, verify that ACME-Cache-Control: no-store prevents caching. + - client-request: + method: GET + url: /acme/test2 + version: '1.1' + headers: + fields: + - [Host, acme.com] + - [uuid, acme-test2-request2] + + server-response: + status: 404 + reason: Not Found + + # Expect the 404 from the origin server, not the cached 200 response. + proxy-response: + status: 404 + headers: + fields: + - [ Cache-Control, { as: absent } ] + - [ CDN-Cache-Control, { as: absent } ] + - [ ATS-Cache-Control, { as: absent } ] + - [ ACME-Cache-Control, { as: absent } ] diff --git a/tests/gold_tests/cache/targeted-cache-control.test.py b/tests/gold_tests/cache/targeted-cache-control.test.py new file mode 100644 index 00000000000..3bcd84047b8 --- /dev/null +++ b/tests/gold_tests/cache/targeted-cache-control.test.py @@ -0,0 +1,25 @@ +'''Test targeted cache control headers per RFC 9213.''' + +# 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.Summary = ''' +Test targeted cache control headers per RFC 9213. +Verifies that CDN-Cache-Control and other targeted headers can override +standard Cache-Control when properly configured. +''' + +Test.ATSReplayTest(replay_file="replay/targeted-cache-control.replay.yaml")