diff --git a/configs/virtualhost.yaml.default b/configs/virtualhost.yaml.default new file mode 100644 index 00000000000..eef18964a3b --- /dev/null +++ b/configs/virtualhost.yaml.default @@ -0,0 +1,27 @@ +# virtualhost.yaml +# +# This configuration file defines a virtual host that provides domain-scoped configs and +# remap rules, overriding global configs. +# +# Remap rule config flow: +# 1. Resolve to a single virtualhost +# A. Look through exact match virtualhost domains. If found, use virtualhost config. +# B. Look through wildcard virtualhost domains. If found, use virtualhost config. +# C. If no virtualhost config found, skip to 3. +# 2. Within virtualhost config, use virtualhost remap rules. +# A. Follow remap.yaml format rules. If found, use remap rule. (See remap.yaml for details) +# 3. If no virtualhost or remap rule found, use global remap rules +# +# Example: +# virtualhost: +# - id: example +# domains: +# - example.com +# - "*.com" # Only allow single left-most: "*.[domain]" format +# +# remap: +# - type: map +# from: +# url: http://example.com +# to: +# url: http://origin.example.com/ diff --git a/doc/admin-guide/files/index.en.rst b/doc/admin-guide/files/index.en.rst index 38b1db9b41a..540f6a1c237 100644 --- a/doc/admin-guide/files/index.en.rst +++ b/doc/admin-guide/files/index.en.rst @@ -40,6 +40,7 @@ Configuration Files sni.yaml.en storage.yaml.en strategies.yaml.en + virtualhost.yaml.en jsonrpc.yaml.en :doc:`cache.config.en` @@ -93,6 +94,9 @@ Configuration Files :doc:`strategies.yaml.en` Configures NextHop strategies used with `remap.config` and replaces parent.config. +:doc:`virtualhost.yaml.en` + Defines configuration blocks that apply to a group of domains (virtualhosts). + :doc:`jsonrpc.yaml.en` Defines some of the configurable arguments of the jsonrpc endpoint. diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index af5a927f565..9e990fe3066 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -6350,3 +6350,10 @@ AIO ============ ====================================================================== Note: If you force the backend to use io_uring, you might experience failures with some (older, pre 5.4) kernel versions + +VirtualHost +=========== + +.. ts:cv:: CONFIG proxy.config.virtualhost.filename STRING virtualhost.yaml + + Sets the name of the :file:`virtualhost.yaml` file. diff --git a/doc/admin-guide/files/virtualhost.yaml.en.rst b/doc/admin-guide/files/virtualhost.yaml.en.rst new file mode 100644 index 00000000000..cd1b21151c2 --- /dev/null +++ b/doc/admin-guide/files/virtualhost.yaml.en.rst @@ -0,0 +1,236 @@ + +.. 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. + +.. include:: ../../common.defs + +.. configfile:: virtualhost.yaml + +virtualhost.yaml +**************** + +The :file:`virtualhost.yaml` file defines configuration blocks that apply to a group of domains. +Each virtual host entry defines a set of domains and the remap rules associated with those domains. +Virtual host remap rules override global :file:`remap.yaml` rules but remain fully backward compatible +with existing configurations. If absent, ATS behaves exactly as before. + +Currently, this file only supports :file:`remap.yaml` overrides. Future versions will expand virtual +host support to additional configuration types (e.g. :file:`sni.yaml`, :file:`ssl_multicert.yaml`, +:file:`parent.config`, etc) + +By default this is named :file:`virtualhost.yaml`. The filename can be changed by setting +:ts:cv:`proxy.config.virtualhost.filename`. + + +Configuration +============= + +:file:`virtualhost.yaml` is YAML format with top level namespace **virtualhost** and a list of virtual host +entries. Each virtual host entry must provide an **id** and at least one domain defined in **domains**. + +An example configuration looks like: + +.. code-block:: yaml + + virtualhost: + - id: example + domains: + - example.com + + remap: + - type: map + from: + url: http://example.com + to: + url: http://origin.example.com/ + + +===================== ========================================================== +Field Name Description +===================== ========================================================== +``id`` Virtual host identifier to perform specific operations on +``domains`` List of domains to resolve a request to +``remap`` List of remap rules as defined in remap.yaml +===================== ========================================================== + +``domains`` + Domains can be defined as request domain name or subdomains using wildcard feature. + Wildcard support only allows a single left most ``*``. This does not support regex. + When matching to a virtual host entry, domains with exact match have precedence + over wildcard. If a domain matches to multiple wildcard domains, the most specific + (longest) suffix match is selected. + + For example: + Supported: + - ``foo.example.com`` + - ``*.example.com`` + - ``*.com`` + + NOT Supported: + - ``foo[0-9]+.example.com`` (regex) + - ``bar.*.example.net`` (``*`` in the middle) + - ``*.bar.*.com`` (multiple ``*``) + - ``*.*.baz.com`` (multiple ``*``) + - ``baz*.example.net`` (partial wildcard) + - ``*baz.example.net`` (partial wildcard) + - ``b*z.example.net`` (partial wildcard) + - ``*`` (global) + +Evaluation Order +---------------- + +|TS| evaluates a request using deterministic precedence in the following order: + +1. Resolve to a single virtualhost + a. Check for an exact domain match. If any virtual host lists the request hostname explicitly, that virtual host is selected. + b. Check for a wildcard domain match. If any virtual host wildcard domains define a subdomain of the request hostname in the form ``*.[domain]``, that virtual host is selected. + c. If no matching virtual host exists, the request proceeds using global configuration (i.e :file:`remap.config`). Skip to step 3. +2. Within selected virtual host config, use virtual host remap rules. + a. Follow existing :file:`remap.yaml` rules and matching orders. If a matching remap rule is found, that remap rule is selected. +3. If neither virtual host nor remap rules match, ATS falls back to global :file:`remap.yaml` resolution. + +Only one virtual host entry may match a given request. Exact domain matches take precedence over wildcard matches. For wildcard matches, +ATS selects the most specific (longest) matching suffix (e.g. ``*.example.com`` before ``*.com``). + + +Granular Reload +=============== + +|TS| now supports granular configuration reloads for individual virtual hosts defined in :file:`virtualhost.yaml`. +In addition to reloading the entire |TS| configuration with :option:`traffic_ctl config reload`, users can +selectively reload a single virtual host entry without affecting other virtual host entries. + +By only updating the necessary changes, this reduces configuration deployment time and improves visibility on the changes made. + +To reload for a specific virtual host, use new reload directive: + +:: + + $ traffic_ctl config reload -D virtualhost.id= + +Where **** is the virtual host ID defined in :file:`virtualhost.yaml`. Only the **** virtual host +configuration will be reloaded. This does not affect other virtual hosts or global configuration files. + +Example: + +:: + + $ traffic_ctl config reload -D virtualhost.id=foo + ✔ Reload scheduled [rpc-123456789] + + Monitor : traffic_ctl config reload -t rpc-123456789 -m + Details : traffic_ctl config reload -t rpc-123456789 -s -l + + $ traffic_ctl config reload -t rpc-123456789 -s -l + ✗ Token 'rpc-123456789' already in use + ✔ Reload [success] — rpc-123456789 + Started : 2026 May 19 19:20:20.691 + Finished: 2026 May 19 19:20:20.692 + Duration: 1ms + + ✔ 1 success ◌ 0 in-progress ✗ 0 failed (1 total) + + Tasks: + ✔ virtualhost ·································· 1ms + [Note] Reloaded virtualhost entry: foo + +The **** must name an entry. An empty value is rejected rather than treated as a request to +reload the whole file, so a reload scoped to one entry never rebuilds the entire table from disk. + +A reload, whether of a single entry or of the whole file, fails and leaves the running +configuration in place if :file:`virtualhost.yaml` is missing, empty, or invalid. Only the initial +load at startup treats a missing or empty file as "no virtual hosts configured"; on a reload, +replacing a live routing table with an empty one is reported as a failure instead. + + +Examples +======== + +.. code-block:: yaml + + # virtualhost.yaml + virtualhost: + - id: example + domains: + - example.com + + remap: + - type: map + from: + url: http://example.com + to: + url: http://origin.example.com/ + + # remap.yaml + remap: + - type: map + from: + url: http://www.x.com + to: + url: http://other.example.com/ + +These rules translate in the following translation. + +================================================ ======================================================== +Client Request Translated Request +================================================ ======================================================== +``http://example.com/index.html`` ``http://origin.example.com/index.html`` +``http://www.x.com/index.html`` ``http://other.example.com/index.html`` +================================================ ======================================================== + +.. code-block:: yaml + + # virtualhost.yaml + virtualhost: + - id: example + domains: + - "*.example.com" + + remap: + - type: regex_map + from: + url: http://sub([0-9]+).example.com/ + to: + url: http://origin$1.example.com/ + + + - id: foo + domains: + - foo.example.com + + remap: + - type: map + from: + url: http://foo.example.com/ + to: + url: http://foo.origin.com/ + +This rules translates in the following translation. + +================================================ ======================================================== +Client Request Translated Request +================================================ ======================================================== +``http://sub0.example.com/index.html`` ``http://origin0.example.com/index.html`` +``http://foo.example.com/index.html`` ``http://foo.origin.com/index.html`` +``http://bar.example.com/index.html`` No remap rule found in virtual host entry `example` +================================================ ======================================================== + + +See Also +======== + +:file:`remap.yaml` diff --git a/include/proxy/ReverseProxy.h b/include/proxy/ReverseProxy.h index 76c4db6aaab..1f08f335960 100644 --- a/include/proxy/ReverseProxy.h +++ b/include/proxy/ReverseProxy.h @@ -32,6 +32,8 @@ #pragma once +#include + #include "records/RecProcess.h" #include "tscore/ink_defs.h" @@ -50,6 +52,9 @@ struct host_hdr_info; extern AtomicSharedPtr rewrite_table; +// Wrap a freshly built table in a shared_ptr with the standard UrlRewrite deleter. +std::shared_ptr make_managed_url_rewrite(std::unique_ptr table); + // API Functions int init_reverse_proxy(); diff --git a/include/proxy/VirtualHost.h b/include/proxy/VirtualHost.h new file mode 100644 index 00000000000..e6aa72d5f80 --- /dev/null +++ b/include/proxy/VirtualHost.h @@ -0,0 +1,136 @@ +/** @file + Virtual Host configuration + @section license License + 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "iocore/eventsystem/ConfigProcessor.h" +#include "mgmt/config/ConfigContext.h" +#include "proxy/http/remap/UrlRewrite.h" +#include "tscore/Ptr.h" + +class VirtualHostPluginReload; + +class VirtualHostConfig : public ConfigInfo +{ +public: + VirtualHostConfig() = default; + VirtualHostConfig(const VirtualHostConfig &other) + : _entries(other._entries), + _exact_domains_to_id(other._exact_domains_to_id), + _wildcard_domains_to_id(other._wildcard_domains_to_id) + { + } + VirtualHostConfig & + operator=(const VirtualHostConfig &other) + { + if (this != &other) { + _entries = other._entries; + _exact_domains_to_id = other._exact_domains_to_id; + _wildcard_domains_to_id = other._wildcard_domains_to_id; + } + return *this; + } + ~VirtualHostConfig() = default; + + struct Entry : public RefCountObjInHeap { + std::string id; + std::vector exact_domains; + std::vector wildcard_domains; + std::shared_ptr remap_table; + + std::string get_id() const; + }; + + /** Load every entry from the configuration file into this (empty) config. + + @param initial_load Set only for the load performed by @c VirtualHost::startup(). An absent + or empty file is a supported "no virtualhosts configured" state there, but on a reload it is + an error: reporting success would publish an empty config over a live routing table, silently + dropping every per-domain remap table. + */ + bool load(ConfigContext ctx = {}, bool initial_load = false, VirtualHostPluginReload *plugin_reload = nullptr); + bool set_entry(std::string_view id, Ptr &entry, ConfigContext ctx = {}); + static bool load_entry(std::string_view id, Ptr &entry, ConfigContext ctx = {}, + VirtualHostPluginReload *plugin_reload = nullptr); + Ptr find_by_id(std::string_view id) const; + Ptr find_by_domain(std::string_view domain) const; + + /// Add the remap plugins instantiated by every entry's remap table to @a used. + void collect_used_plugins(std::unordered_map &used) const; + + size_t + entry_count() const + { + return _entries.size(); + } + +private: + using entry_map = std::unordered_map>; + using name_map = std::unordered_map; + + entry_map _entries; + name_map _exact_domains_to_id; + name_map _wildcard_domains_to_id; +}; + +/** Sends one remap plugin reload notification pair for a whole virtualhost rebuild. + + The pre/post callbacks go to every loaded remap plugin, not just the ones a table uses, so + notifying per table would repeat them for each entry and report plugins used only by earlier + tables as unused. The pre notification is sent lazily, before the first remap table is built, so + a rebuild with no remap tables sends nothing. If @c finish() is never reached, the destructor + reports the reload as failed. + */ +class VirtualHostPluginReload +{ +public: + VirtualHostPluginReload() = default; + VirtualHostPluginReload(const VirtualHostPluginReload &) = delete; + VirtualHostPluginReload &operator=(const VirtualHostPluginReload &) = delete; + ~VirtualHostPluginReload(); + + /// Call before building a remap table. + void begin(); + /// Report success, with @a config as the full set of tables that will be live. + void finish(VirtualHostConfig const &config); + +private: + bool _started = false; +}; + +class VirtualHost +{ +public: + using scoped_config = ConfigProcessor::scoped_config; + + static void startup(); + static int reconfigure(ConfigContext ctx = {}, bool initial_load = false); + static int reconfigure(std::string_view id, ConfigContext ctx = {}); + static VirtualHostConfig *acquire(); + static void release(VirtualHostConfig *config); + +private: + static std::atomic _configid; +}; diff --git a/include/proxy/http/HttpSM.h b/include/proxy/http/HttpSM.h index 9945273c768..9545a36bb43 100644 --- a/include/proxy/http/HttpSM.h +++ b/include/proxy/http/HttpSM.h @@ -43,6 +43,7 @@ #include "proxy/http/HttpTunnel.h" #include "api/InkAPIInternal.h" #include "proxy/ProxyTransaction.h" +#include "proxy/VirtualHost.h" // inknet #include "proxy/http/PreWarmManager.h" @@ -331,7 +332,8 @@ class HttpSM : public Continuation, public PluginUserArgs // This unfortunately can't go into the t_state, because of circular dependencies. We could perhaps refactor // this, with a lot of work, but this is easier for now. - std::shared_ptr m_remap; + std::shared_ptr m_remap; + Ptr m_virtualhost_entry; History history; NetVConnection * @@ -389,6 +391,7 @@ class HttpSM : public Continuation, public PluginUserArgs // Y! ebalsa: remap handlers int state_remap_request(int event, void *data); + void set_virtualhost_entry(std::string_view domain); void do_remap_request(bool); // Cache Handlers diff --git a/include/proxy/http/remap/PluginFactory.h b/include/proxy/http/remap/PluginFactory.h index fd08c00000f..26f69e8c44a 100644 --- a/include/proxy/http/remap/PluginFactory.h +++ b/include/proxy/http/remap/PluginFactory.h @@ -103,6 +103,7 @@ class PluginFactory void deactivate(); void indicatePreReload(); void indicatePostReload(bool reloadSuccessful); + void collectUsedPlugins(std::unordered_map &pluginUsed); static void cleanup(); // For startup, clean out all temporary directory we may have left from before diff --git a/include/proxy/http/remap/RemapYamlConfig.h b/include/proxy/http/remap/RemapYamlConfig.h index a93ee9d8b10..fc385d55e7b 100644 --- a/include/proxy/http/remap/RemapYamlConfig.h +++ b/include/proxy/http/remap/RemapYamlConfig.h @@ -72,3 +72,8 @@ swoc::Errata parse_yaml_remap_rule(const YAML::Node &node, BUILD_TABLE_INFO *bti bool remap_parse_yaml_bti(const char *path, BUILD_TABLE_INFO *bti, ConfigContext ctx = {}); bool remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx = {}); +// Parse remap YAML node from inline YAML node (for virtualhost) +bool remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, ConfigContext ctx = {}); + +// Unlike the file overload, this does not send the remap plugin reload notifications; the caller owns them. +bool remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite, ConfigContext ctx = {}); diff --git a/include/proxy/http/remap/UrlRewrite.h b/include/proxy/http/remap/UrlRewrite.h index 58fa755a82c..eb6c27df7de 100644 --- a/include/proxy/http/remap/UrlRewrite.h +++ b/include/proxy/http/remap/UrlRewrite.h @@ -80,13 +80,21 @@ class UrlRewrite */ bool load(ConfigContext ctx = {}); + /** Build the tables from @a path, or from @a remap_node when it is set. + * + * @param path Configuration file to parse. Ignored when @a remap_node is set, so inline callers pass it empty. + * @param remap_node Inline remap.yaml style rule sequence, or @c nullptr to parse @a path. + * @param ctx ConfigContext for reload status tracking. + */ + bool load_table(const std::string &path, YAML::Node const *remap_node, ConfigContext ctx = {}); + /** Build the internal url write tables. * * @param path Path to configuration file. * @param ctx ConfigContext for reload status tracking. * @return 0 on success, non-zero error code on failure. */ - int BuildTable(const char *path, ConfigContext ctx = {}); + int BuildTable(const char *path, YAML::Node const *remap_node = nullptr, ConfigContext ctx = {}); mapping_type Remap_redirect(HTTPHdr *request_header, URL *redirect_url); bool ReverseMap(HTTPHdr *response_header); diff --git a/include/tscore/Filenames.h b/include/tscore/Filenames.h index b36e282aebe..d828eda9218 100644 --- a/include/tscore/Filenames.h +++ b/include/tscore/Filenames.h @@ -44,6 +44,7 @@ namespace filename constexpr const char *SPLITDNS = "splitdns.config"; constexpr const char *SNI = "sni.yaml"; constexpr const char *JSONRPC = "jsonrpc.yaml"; + constexpr const char *VIRTUALHOST = "virtualhost.yaml"; /////////////////////////////////////////////////////////////////// // Various other file names diff --git a/src/proxy/CMakeLists.txt b/src/proxy/CMakeLists.txt index f4c74c8c8de..101bb0e382d 100644 --- a/src/proxy/CMakeLists.txt +++ b/src/proxy/CMakeLists.txt @@ -37,6 +37,7 @@ add_library( Transform.cc FetchSM.cc PluginHttpConnect.cc + VirtualHost.cc ) add_library(ts::proxy ALIAS proxy) diff --git a/src/proxy/ReverseProxy.cc b/src/proxy/ReverseProxy.cc index da47dc08e52..c3657bb7a0f 100644 --- a/src/proxy/ReverseProxy.cc +++ b/src/proxy/ReverseProxy.cc @@ -44,6 +44,7 @@ #include "proxy/http/remap/UrlRewrite.h" #include "proxy/http/remap/UrlMapping.h" #include "proxy/http/remap/UrlMappingPathIndex.h" +#include "proxy/VirtualHost.h" namespace { @@ -81,6 +82,12 @@ struct UrlRewriteDeleter { AtomicSharedPtr rewrite_table; thread_local PluginThreadContext *pluginThreadContext = nullptr; +std::shared_ptr +make_managed_url_rewrite(std::unique_ptr table) +{ + return std::shared_ptr(table.release(), UrlRewriteDeleter{}); +} + void shutdown_url_rewrite() { @@ -144,7 +151,7 @@ init_reverse_proxy() init_table_volume_host_records(*initial_table); } - rewrite_table.store(std::shared_ptr(initial_table.release(), UrlRewriteDeleter{}), std::memory_order_release); + rewrite_table.store(make_managed_url_rewrite(std::move(initial_table)), std::memory_order_release); ink_assert(0 == config_reg.attach("remap", "proxy.config.url_remap.filename")); ink_assert(0 == config_reg.attach("remap", "proxy.config.proxy_name")); ink_assert(0 == config_reg.attach("remap", "proxy.config.http.referer_default_redirect")); @@ -153,6 +160,8 @@ init_reverse_proxy() ink_assert(0 == config_reg.attach("remap_yaml", "proxy.config.http.referer_default_redirect")); RecRegisterConfigUpdateCb("proxy.config.reverse_proxy.enabled", url_rewrite_CB, (void *)REVERSE_CHANGED); + VirtualHost::startup(); + return 0; } @@ -208,7 +217,7 @@ reloadUrlRewrite(ConfigContext ctx) if (status) { swoc::bwprint(msg_buffer, "{} finished loading", is_yaml ? ts::filename::REMAP_YAML : ts::filename::REMAP); - rewrite_table.exchange(std::shared_ptr(newTable.release(), UrlRewriteDeleter{}), std::memory_order_acq_rel); + rewrite_table.exchange(make_managed_url_rewrite(std::move(newTable)), std::memory_order_acq_rel); Dbg(dbg_ctl_url_rewrite, "%s", msg_buffer.c_str()); CfgLoadComplete(ctx, "%s finished loading", is_yaml ? ts::filename::REMAP_YAML : ts::filename::REMAP); diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc new file mode 100644 index 00000000000..f02ae6348fb --- /dev/null +++ b/src/proxy/VirtualHost.cc @@ -0,0 +1,645 @@ +/** @file + + Virtual Host configuration implementation + + @section license License + + 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. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "proxy/VirtualHost.h" +#include "proxy/ReverseProxy.h" +#include "mgmt/config/ConfigContextDiags.h" +#include "mgmt/config/ConfigRegistry.h" +#include "records/RecCore.h" +#include "tscore/Filenames.h" +#include "tsutil/Convert.h" + +namespace +{ +DbgCtl dbg_ctl_virtualhost("virtualhost"); + +/** Serializes both reload paths against each other. + + Each reload must hold this from the moment it reads the file until it publishes. Otherwise two + reloads that parse concurrently can publish in the opposite order to their reads, and the older + read wins: a full reload resurrects entries a newer one had dropped, and a single-entry reload + parsed before a full reload that removed its id re-adds that entry on top of it. The single-entry + reload additionally does a read-copy-modify-publish against the live config, which the same + critical section keeps atomic. It also keeps the remap plugin reload notifications of one rebuild + from interleaving with another's. Reload paths are scheduled on ET_TASK with unrelated mutexes + (a fresh one per @c ConfigRegistry::schedule_reload and one per trigger record), so nothing else + serializes them. Neither path is on the request path, so serializing the parse costs nothing. + */ +std::mutex vhost_reconfigure_mutex; +} // namespace + +std::atomic VirtualHost::_configid{0}; + +std::string +VirtualHostConfig::Entry::get_id() const +{ + return id; +} + +namespace +{ +const std::set valid_vhost_keys = {"id", "domains", "remap"}; + +/** Check that @a name is composed of non-empty hostname labels. + + Without this, a documented-as-unsupported form such as `foo[0-9]+.example.com` is accepted as + an exact domain that no Host header can ever equal: the entry loads clean and never fires. + */ +bool +is_hostname(std::string_view name) +{ + // A bracketed IPv6 literal is matched verbatim against the Host header. + if (name.size() > 2 && name.front() == '[' && name.back() == ']') { + return true; + } + + size_t label_len = 0; + + for (char c : name) { + if (c == '.') { + if (label_len == 0) { + return false; + } + label_len = 0; + continue; + } + if (!isalnum(static_cast(c)) && c != '-' && c != '_') { + return false; + } + ++label_len; + } + return label_len > 0; +} + +/** Decode a single `virtualhost` sequence element. + + Diagnostics go through @a ctx so the operator who asked for the reload sees them in the reload + task log, not just in diags.log. + */ +bool +decode_virtualhost_entry(YAML::Node const &node, VirtualHostConfig::Entry &item, ConfigContext ctx) +{ + if (!node["id"]) { + CfgLoadLog(ctx, DL_Error, "Virtualhost entry at line %d must provide `id`", node.Mark().line + 1); + return false; + } + item.id = node["id"].as(); + if (item.id.empty()) { + // A single-entry reload cannot name an empty id, so such an entry could never be reloaded on its own. + CfgLoadLog(ctx, DL_Error, "Virtualhost entry at line %d must provide a non-empty `id`", node.Mark().line + 1); + return false; + } + + for (const auto &elem : node) { + auto key = elem.first.as(); + if (!valid_vhost_keys.contains(key)) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' has unsupported key '%s' (line %d)", item.id.c_str(), key.c_str(), + elem.first.Mark().line + 1); + return false; + } + } + + auto domains = node["domains"]; + if (!domains || !domains.IsSequence() || domains.size() == 0) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' must provide at least one domain in a `domains` sequence (line %d)", + item.id.c_str(), node.Mark().line + 1); + return false; + } + item.exact_domains.clear(); + item.wildcard_domains.clear(); + + for (const auto &it : domains) { + auto domain_entry = it.as(); + if (domain_entry.empty()) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' has an empty entry in `domains` (line %d)", item.id.c_str(), it.Mark().line + 1); + return false; + } + char domain[TS_MAX_HOST_NAME_LEN + 1]; + ts::transform_lower(domain_entry, domain); + + // Check if domain is wildcard, prefixed with * + if (domain[0] == '*') { + if (domain[1] != '.' || domain[2] == '\0' || domain[2] == '.' || strchr(domain + 2, '*') != nullptr) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' wildcard '%s' must match '*.[domain]' format (line %d)", item.id.c_str(), + domain, it.Mark().line + 1); + return false; + } + if (!is_hostname(domain + 2)) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' wildcard '%s' suffix is not a valid hostname; regex is not supported (line %d)", + item.id.c_str(), domain, it.Mark().line + 1); + return false; + } + item.wildcard_domains.emplace_back(domain + 2); + } else { + if (strchr(domain, '*') != nullptr) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' domain '%s' may only use a wildcard in the leading '*.[domain]' form (line %d)", + item.id.c_str(), domain, it.Mark().line + 1); + return false; + } + if (!is_hostname(domain)) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' domain '%s' is not a valid hostname; regex is not supported (line %d)", + item.id.c_str(), domain, it.Mark().line + 1); + return false; + } + item.exact_domains.emplace_back(domain); + } + } + + if (item.exact_domains.empty() && item.wildcard_domains.empty()) { + CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' must have at least one domain defined (line %d)", item.id.c_str(), + node.Mark().line + 1); + return false; + } + + return true; +} + +bool +build_virtualhost_entry(YAML::Node const &node, Ptr &entry, ConfigContext ctx, + VirtualHostPluginReload *plugin_reload) +{ + entry.clear(); + Ptr vhost = make_ptr(new VirtualHostConfig::Entry); + auto &conf = *vhost; + try { + if (!decode_virtualhost_entry(node, conf, ctx)) { + return false; + } + } catch (YAML::Exception const &ex) { + CfgLoadLog(ctx, DL_Error, "Failed to parse virtualhost entry at line %d: %s", node.Mark().line + 1, ex.what()); + return false; + } + + // Build UrlRewrite table for remap rules + auto remap_node = node["remap"]; + if (remap_node) { + if (plugin_reload) { + plugin_reload->begin(); + } + auto table = std::make_unique(); + table->set_remap_yaml(true); + if (!table->load_table({}, &remap_node, ctx)) { + CfgLoadLog(ctx, DL_Error, "Failed to load remap rules for virtualhost '%s' at line %d", conf.id.c_str(), + remap_node.Mark().line + 1); + return false; + } + conf.remap_table = make_managed_url_rewrite(std::move(table)); + } + entry = std::move(vhost); + return true; +} +} // namespace + +namespace +{ +/// Number of entries in the currently published config, so a refused reload can say what it would +/// otherwise have dropped. +size_t +live_entry_count() +{ + VirtualHost::scoped_config config; + + return config ? config->entry_count() : 0; +} +} // namespace + +bool +VirtualHostConfig::load(ConfigContext ctx, bool initial_load, VirtualHostPluginReload *plugin_reload) +{ + _entries.clear(); + _exact_domains_to_id.clear(); + _wildcard_domains_to_id.clear(); + std::string config_path = RecConfigReadConfigPath("proxy.config.virtualhost.filename", ts::filename::VIRTUALHOST); + + struct stat sbuf; + if (stat(config_path.c_str(), &sbuf) == -1 && errno == ENOENT) { + if (!initial_load) { + CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost config: '%s' doesn't exist; keeping the %zu live entry(s)", + config_path.c_str(), live_entry_count()); + return false; + } + CfgLoadLog(ctx, DL_Warning, "Virtualhost configuration '%s' doesn't exist, no virtualhost entries loaded", config_path.c_str()); + return true; + } + + try { + YAML::Node config = YAML::LoadFile(config_path); + if (config.IsNull()) { + if (!initial_load) { + CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost config: '%s' is empty; keeping the %zu live entry(s)", + config_path.c_str(), live_entry_count()); + return false; + } + Dbg(dbg_ctl_virtualhost, "Empty virtualhost config: %s", config_path.c_str()); + return true; + } + + config = config["virtualhost"]; + if (config.IsNull() || !config.IsSequence()) { + CfgLoadLog(ctx, DL_Error, "%s: expected toplevel 'virtualhost' key to be a sequence", config_path.c_str()); + return false; + } + + for (auto const &node : config) { + Ptr entry; + if (!build_virtualhost_entry(node, entry, ctx, plugin_reload)) { + return false; + } + + std::string vhost_id{entry->id}; + if (_entries.contains(vhost_id)) { + CfgLoadLog(ctx, DL_Error, "%s: duplicate virtualhost id '%s' (line %d)", config_path.c_str(), vhost_id.c_str(), + node.Mark().line + 1); + return false; + } + + for (auto const &domain : entry->exact_domains) { + if (_exact_domains_to_id.contains(domain)) { + CfgLoadLog(ctx, DL_Error, "%s: domain '%s' in virtualhost '%s' is already claimed by virtualhost '%s'", + config_path.c_str(), domain.c_str(), vhost_id.c_str(), _exact_domains_to_id.at(domain).c_str()); + return false; + } + _exact_domains_to_id.emplace(domain, vhost_id); + } + + for (auto const &domain_suffix : entry->wildcard_domains) { + if (_wildcard_domains_to_id.contains(domain_suffix)) { + CfgLoadLog(ctx, DL_Error, "%s: wildcard domain '*.%s' in virtualhost '%s' is already claimed by virtualhost '%s'", + config_path.c_str(), domain_suffix.c_str(), vhost_id.c_str(), + _wildcard_domains_to_id.at(domain_suffix).c_str()); + return false; + } + _wildcard_domains_to_id.emplace(domain_suffix, vhost_id); + } + + _entries.emplace(vhost_id, std::move(entry)); + } + + } catch (std::exception &ex) { + CfgLoadLog(ctx, DL_Error, "Failed to load %s: %s", config_path.c_str(), ex.what()); + return false; + } + return true; +} + +bool +VirtualHostConfig::load_entry(std::string_view id, Ptr &entry, ConfigContext ctx, VirtualHostPluginReload *plugin_reload) +{ + entry.clear(); + std::string config_path = RecConfigReadConfigPath("proxy.config.virtualhost.filename", ts::filename::VIRTUALHOST); + + struct stat sbuf; + if (stat(config_path.c_str(), &sbuf) == -1 && errno == ENOENT) { + CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost entry '%.*s': configuration '%s' doesn't exist", + static_cast(id.size()), id.data(), config_path.c_str()); + return false; + } + + try { + YAML::Node config = YAML::LoadFile(config_path); + if (config.IsNull()) { + CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost entry '%.*s': '%s' is empty", static_cast(id.size()), id.data(), + config_path.c_str()); + return false; + } + + config = config["virtualhost"]; + if (config.IsNull() || !config.IsSequence()) { + CfgLoadLog(ctx, DL_Error, "%s: expected toplevel 'virtualhost' key to be a sequence", config_path.c_str()); + return false; + } + + for (auto const &node : config) { + auto config_id = node["id"]; + if (!config_id || config_id.as() != id) { + continue; + } + + Ptr vhost_entry; + if (!build_virtualhost_entry(node, vhost_entry, ctx, plugin_reload)) { + return false; + } + entry = std::move(vhost_entry); + return true; + } + + } catch (std::exception &ex) { + CfgLoadLog(ctx, DL_Error, "Failed to load virtualhost entry '%.*s' in %s: %s", static_cast(id.size()), id.data(), + config_path.c_str(), ex.what()); + return false; + } + CfgLoadLog(ctx, DL_Error, "%s: virtualhost with id '%.*s' not found", config_path.c_str(), static_cast(id.size()), + id.data()); + return false; +} + +bool +VirtualHostConfig::set_entry(std::string_view id, Ptr &entry, ConfigContext ctx) +{ + std::string vhost_id{id}; + // If virtualhost entry already exists, remove current entry + if (auto it = _entries.find(vhost_id); it != _entries.end()) { + Ptr curr_entry = std::move(it->second); + for (auto const &domain : curr_entry->exact_domains) { + _exact_domains_to_id.erase(domain); + } + for (auto const &domain : curr_entry->wildcard_domains) { + _wildcard_domains_to_id.erase(domain); + } + _entries.erase(vhost_id); + } + + // Add new entry into virtualhost config + if (entry) { + for (auto const &domain : entry->exact_domains) { + if (_exact_domains_to_id.contains(domain)) { + CfgLoadLog(ctx, DL_Error, "Domain '%s' in virtualhost '%s' is already claimed by virtualhost '%s'", domain.c_str(), + vhost_id.c_str(), _exact_domains_to_id.at(domain).c_str()); + return false; + } + _exact_domains_to_id.emplace(domain, vhost_id); + } + + for (auto const &domain_suffix : entry->wildcard_domains) { + if (_wildcard_domains_to_id.contains(domain_suffix)) { + CfgLoadLog(ctx, DL_Error, "Wildcard domain '*.%s' in virtualhost '%s' is already claimed by virtualhost '%s'", + domain_suffix.c_str(), vhost_id.c_str(), _wildcard_domains_to_id.at(domain_suffix).c_str()); + return false; + } + _wildcard_domains_to_id.emplace(domain_suffix, vhost_id); + } + + _entries.emplace(vhost_id, std::move(entry)); + } + return true; +} + +void +VirtualHostConfig::collect_used_plugins(std::unordered_map &used) const +{ + for (auto const &[id, entry] : _entries) { + if (entry->remap_table) { + entry->remap_table->pluginFactory.collectUsedPlugins(used); + } + } +} + +VirtualHostPluginReload::~VirtualHostPluginReload() +{ + if (_started) { + PluginDso::loadedPlugins()->indicatePostReload(false, {}, "virtualhost"); + } +} + +void +VirtualHostPluginReload::begin() +{ + if (!_started) { + PluginDso::loadedPlugins()->indicatePreReload("virtualhost"); + _started = true; + } +} + +void +VirtualHostPluginReload::finish(VirtualHostConfig const &config) +{ + if (!_started) { + return; + } + + std::unordered_map used; + config.collect_used_plugins(used); + + PluginDso::loadedPlugins()->indicatePostReload(true, used, "virtualhost"); + _started = false; +} + +Ptr +VirtualHostConfig::find_by_id(std::string_view id) const +{ + if (_entries.empty()) { + return Ptr(); + } + + auto entry = _entries.find(std::string{id}); + if (entry != _entries.end()) { + return entry->second; + } + return Ptr(); +} + +Ptr +VirtualHostConfig::find_by_domain(std::string_view domain) const +{ + if (_entries.empty() || domain.empty()) { + return Ptr(); + } + + char lower_domain[TS_MAX_HOST_NAME_LEN + 1]; + ts::transform_lower(std::string{domain}, lower_domain); + + // Check for exact match domains first + auto id = _exact_domains_to_id.find(lower_domain); + if (id != _exact_domains_to_id.end()) { + auto entry = _entries.find(id->second); + if (entry != _entries.end()) { + return entry->second; + } + } + + // Check wildcard suffixes + const char *subdomain = index(lower_domain, '.'); + while (subdomain) { + subdomain++; + if (auto suffix_id = _wildcard_domains_to_id.find(subdomain); suffix_id != _wildcard_domains_to_id.end()) { + auto entry = _entries.find(suffix_id->second); + if (entry != _entries.end()) { + return entry->second; + } + } + subdomain = index(subdomain, '.'); + } + + return Ptr(); +} + +namespace +{ +/** Reload handler for the `virtualhost` config. + + Registered as FileAndRpc so that `admin_config_reload` can carry `_reload` directives (currently + just `id`, for a single-entry reload). Pushed config *content* is deliberately not supported: + both reload paths re-read the on-disk file, so silently dropping a supplied body would report + success for a change that never took effect. + */ +void +virtualhost_reload(ConfigContext ctx) +{ + ctx.in_progress(); + + if (ctx.supplied_yaml()) { + ctx.fail("virtualhost does not accept config content over rpc; only '_reload' directives are supported. " + "Update " + + std::string{ts::filename::VIRTUALHOST} + " and reload without a body."); + return; + } + + // Single-entry reload requested via -D virtualhost.id= + if (auto directives = ctx.reload_directives(); directives) { + // An unrecognized key (e.g. a mistyped 'ID') would otherwise fall through to a full reload. + for (const auto &kv : directives) { + std::string key = kv.first.IsScalar() ? kv.first.as() : std::string{""}; + if (key != "id") { + ctx.fail("virtualhost '_reload' directive '" + key + "' is not supported; only 'id' is recognized"); + return; + } + } + if (const auto id_dir = directives["id"]; id_dir) { + if (!id_dir.IsScalar()) { + ctx.fail("virtualhost '_reload' directive 'id' must be a scalar"); + return; + } + std::string id = id_dir.as(); + if (id.empty()) { + // An empty id would otherwise fall through to a full reload, turning a request scoped to + // one entry into a rebuild of the whole table from whatever is currently on disk. + ctx.fail("virtualhost '_reload' directive 'id' must name an entry; omit 'id' entirely to reload the whole file"); + return; + } + if (VirtualHost::reconfigure(id, ctx)) { + ctx.complete("Reloaded virtualhost entry: " + id); + } else { + ctx.fail("Failed to reload virtualhost entry: " + id); + } + return; + } + } + + if (VirtualHost::reconfigure(ctx)) { + ctx.complete("Finished loading virtualhost config"); + } else { + ctx.fail("Failed to load virtualhost config"); + } +} +} // namespace + +void +VirtualHost::startup() +{ + if (!reconfigure({}, true)) { + Fatal("failed to load %s", ts::filename::VIRTUALHOST); + } + + config::ConfigRegistry::Get_Instance().register_config( + "virtualhost", // registry key + ts::filename::VIRTUALHOST, // default filename + "proxy.config.virtualhost.filename", // record holding the filename + virtualhost_reload, // reload handler + config::ConfigSource::FileAndRpc, // rpc may supply '_reload' directives; content is rejected + {"proxy.config.virtualhost.filename"}); // trigger records +} + +int +VirtualHost::reconfigure(ConfigContext ctx, bool initial_load) +{ + CfgLoadLog(ctx, DL_Note, "%s loading ...", ts::filename::VIRTUALHOST); + auto config = std::make_unique(); + + // The parse is inside the lock as well as the publish; see vhost_reconfigure_mutex. + std::scoped_lock lock(vhost_reconfigure_mutex); + VirtualHostPluginReload plugin_reload; + + if (!config->load(ctx, initial_load, &plugin_reload)) { + CfgLoadLog(ctx, DL_Error, "%s failed to load", ts::filename::VIRTUALHOST); + return 0; + } + plugin_reload.finish(*config); + _configid = configProcessor.set(_configid, config.release()); + + CfgLoadLog(ctx, DL_Note, "%s finished loading", ts::filename::VIRTUALHOST); + return 1; +} + +int +VirtualHost::reconfigure(std::string_view id, ConfigContext ctx) +{ + Dbg(dbg_ctl_virtualhost, "Reconfiguring virtualhost entry: %.*s", static_cast(id.size()), id.data()); + if (id.empty()) { + // Reinterpreting this as a full reload would let a narrowly scoped request rebuild the whole + // table from disk. Callers that want that must ask for it directly. + CfgLoadLog(ctx, DL_Error, "Cannot reload a virtualhost entry without an id"); + return 0; + } + + // The parse is inside the lock as well as the publish; see vhost_reconfigure_mutex. + std::scoped_lock lock(vhost_reconfigure_mutex); + VirtualHostPluginReload plugin_reload; + + Ptr entry; + if (!VirtualHostConfig::load_entry(id, entry, ctx, &plugin_reload)) { + return 0; + } + + VirtualHost::scoped_config vhost_config; + std::unique_ptr config; + + if (vhost_config) { + config = std::make_unique(*vhost_config); + } else { + config = std::make_unique(); + } + + if (!config->set_entry(id, entry, ctx)) { + return 0; + } + plugin_reload.finish(*config); + _configid = configProcessor.set(_configid, config.release()); + return 1; +} + +VirtualHostConfig * +VirtualHost::acquire() +{ + return static_cast(configProcessor.get(_configid)); +} + +void +VirtualHost::release(VirtualHostConfig *config) +{ + if (config && _configid > 0) { + configProcessor.release(_configid, config); + } +} diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 3b37e4e5ede..f35443e4e81 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -4705,13 +4705,53 @@ HttpSM::check_sni_host() } } +void +HttpSM::set_virtualhost_entry(std::string_view domain) +{ + VirtualHost::scoped_config vhost_config; + // If already set, don't need to look at configs + if (m_virtualhost_entry || domain.empty() || !vhost_config) { + return; + } + + auto vhost_entry = vhost_config->find_by_domain(domain); + if (vhost_entry) { + SMDbg(dbg_ctl_url_rewrite, "Found virtualhost: %s", vhost_entry->get_id().c_str()); + m_virtualhost_entry = std::move(vhost_entry); + } +} + void HttpSM::do_remap_request(bool run_inline) { SMDbg(dbg_ctl_http_seq, "Remapping request"); SMDbg(dbg_ctl_url_rewrite, "Starting a possible remapping for request"); + + if (!m_virtualhost_entry) { + auto host_name{t_state.hdr_info.client_request.host_get()}; + set_virtualhost_entry(host_name); + } + + // Check virtualhost remap rules before looking at remap.config. Copying the shared_ptr pins the + // table for the life of this transaction, so a reload that drops the entry cannot pull the table + // out from under us mid-transaction. + bool virtualhost_remap = false; + if (m_virtualhost_entry && m_virtualhost_entry->remap_table) { + m_remap = m_virtualhost_entry->remap_table; + virtualhost_remap = true; + SMDbg(dbg_ctl_url_rewrite, "Using virtualhost remap table: %s", m_virtualhost_entry->get_id().c_str()); + } + bool ret = remapProcessor.setup_for_remap(&t_state, m_remap.get()); + // If no remap matches in virtualhost, revert to default remap configs + if (!ret && virtualhost_remap) { + SMDbg(dbg_ctl_url_rewrite, "No virtualhost remap rules found: using global remap table"); + // May be null once shutdown has cleared the table; setup_for_remap() handles that. + m_remap = rewrite_table.load(std::memory_order_acquire); + ret = remapProcessor.setup_for_remap(&t_state, m_remap.get()); + } + check_sni_host(); if (!ret) { diff --git a/src/proxy/http/remap/PluginFactory.cc b/src/proxy/http/remap/PluginFactory.cc index f68eebc697e..254fdc48940 100644 --- a/src/proxy/http/remap/PluginFactory.cc +++ b/src/proxy/http/remap/PluginFactory.cc @@ -344,11 +344,19 @@ PluginFactory::indicatePreReload() void PluginFactory::indicatePostReload(bool reloadSuccessful) { - /* Find out which plugins (DSO) are actually instantiated by this factory */ std::unordered_map pluginUsed; + collectUsedPlugins(pluginUsed); + + PluginDso::loadedPlugins()->indicatePostReload(reloadSuccessful, pluginUsed, getUuid()); +} + +/** + * @brief Add the plugins (DSO) actually instantiated by this factory to @a pluginUsed + */ +void +PluginFactory::collectUsedPlugins(std::unordered_map &pluginUsed) +{ for (auto &inst : _instList) { pluginUsed[&(inst._plugin)]++; } - - PluginDso::loadedPlugins()->indicatePostReload(reloadSuccessful, pluginUsed, getUuid()); } diff --git a/src/proxy/http/remap/RemapYamlConfig.cc b/src/proxy/http/remap/RemapYamlConfig.cc index c2665e050c0..79b9b3bee8f 100644 --- a/src/proxy/http/remap/RemapYamlConfig.cc +++ b/src/proxy/http/remap/RemapYamlConfig.cc @@ -1026,6 +1026,51 @@ remap_parse_yaml_bti(const char *path, BUILD_TABLE_INFO *bti, ConfigContext ctx) return false; } +bool +remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, ConfigContext ctx) +{ + try { + if (!remap_node || remap_node->IsNull() || !remap_node->IsSequence()) { + Dbg(dbg_ctl_remap_yaml, "Remap node must be a sequence"); + return false; + } + + Dbg(dbg_ctl_url_rewrite, "[BuildTable] UrlRewrite::BuildTable()"); + + ACLBehaviorPolicy behavior_policy = ACLBehaviorPolicy::ACL_BEHAVIOR_LEGACY; + if (!UrlRewrite::get_acl_behavior_policy(behavior_policy)) { + CfgLoadLog(ctx, DL_Warning, "Failed to get ACL matching policy."); + return false; + } + bti->behavior_policy = behavior_policy; + + for (const auto &rule : *remap_node) { + bti->reset(); + + auto errata = parse_yaml_remap_rule(rule, bti); + if (!errata.is_ok()) { + CfgLoadLog(ctx, DL_Error, "Failed to parse remap rule"); + return false; + } + } + + // Deliberately do NOT call IpAllow::enableAcceptCheck() here. accept_check_p is a single + // process-wide flag owned by the global remap table; the accept-time fast-deny decision is made + // before any host is known, so a per-domain rule set cannot meaningfully influence it. Writing it + // from here would let the last inline config parsed silently override a `deactivate_filter: + // ip_allow` in the global remap config. + + Dbg(dbg_ctl_remap_yaml, "Successfully parsed inline remap YAML rules"); + return true; + + } catch (YAML::Exception &ex) { + CfgLoadLog(ctx, DL_Error, "YAML parsing error in inline remap rules: %s", ex.what()); + } catch (std::exception &ex) { + CfgLoadLog(ctx, DL_Error, "Exception parsing inline remap YAML rules: %s", ex.what()); + } + return false; +} + bool remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx) { @@ -1046,3 +1091,18 @@ remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx) return status; } + +bool +remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite, ConfigContext ctx) +{ + BUILD_TABLE_INFO bti; + + // No plugin reload notification here: the caller builds several tables per reload and must send + // one pre/post pair for all of them, see VirtualHostPluginReload. + bti.rewrite = rewrite; + bool status = remap_parse_yaml_bti(remap_node, &bti, ctx); + + bti.clear_acl_rules_list(); + + return status; +} diff --git a/src/proxy/http/remap/UrlRewrite.cc b/src/proxy/http/remap/UrlRewrite.cc index a70dbf7b54d..6b8387218af 100644 --- a/src/proxy/http/remap/UrlRewrite.cc +++ b/src/proxy/http/remap/UrlRewrite.cc @@ -97,7 +97,12 @@ UrlRewrite::load(ConfigContext ctx) return false; } } + return load_table(std::string(config_file_path.get()), nullptr, ctx); +} +bool +UrlRewrite::load_table(const std::string &path, YAML::Node const *remap_node, ConfigContext ctx) +{ this->ts_name = nullptr; if (auto rec_str{RecGetRecordStringAlloc("proxy.config.proxy_name")}; rec_str) { this->ts_name = ats_stringdup(rec_str); @@ -145,7 +150,7 @@ UrlRewrite::load(ConfigContext ctx) Dbg(dbg_ctl_url_rewrite_regex, "strategyFactory file: %s", sf.c_str()); strategyFactory = new NextHopStrategyFactory(sf.c_str()); - if (TS_SUCCESS == this->BuildTable(config_file_path, ctx)) { + if (TS_SUCCESS == this->BuildTable(path.c_str(), remap_node, ctx)) { int n_rules = this->rule_count(); // Minimum # of rules to be considered a valid configuration. int required_rules; required_rules = RecGetRecordInt("proxy.config.url_remap.min_rules_required").value_or(0); @@ -819,7 +824,7 @@ UrlRewrite::InsertForwardMapping(mapping_type maptype, url_mapping *mapping, con */ int -UrlRewrite::BuildTable(const char *path, ConfigContext ctx) +UrlRewrite::BuildTable(const char *path, YAML::Node const *remap_node, ConfigContext ctx) { ink_assert(forward_mappings.empty()); ink_assert(reverse_mappings.empty()); @@ -832,6 +837,10 @@ UrlRewrite::BuildTable(const char *path, ConfigContext ctx) ink_assert(num_rules_redirect_temporary == 0); ink_assert(num_rules_forward_with_recv_port == 0); + // A supplied node is only reachable through the yaml parser; otherwise the node is silently + // dropped and @a path is parsed as a remap.config file name. + ink_release_assert(!remap_node || is_remap_yaml()); + forward_mappings.hash_lookup.reset(new URLTable); reverse_mappings.hash_lookup.reset(new URLTable); permanent_redirects.hash_lookup.reset(new URLTable); @@ -840,7 +849,11 @@ UrlRewrite::BuildTable(const char *path, ConfigContext ctx) bool parse_success; if (is_remap_yaml()) { - parse_success = remap_parse_yaml(path, this, ctx); + if (remap_node) { + parse_success = remap_parse_yaml(remap_node, this, ctx); + } else { + parse_success = remap_parse_yaml(path, this, ctx); + } } else { parse_success = remap_parse_config(path, this, ctx); } diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc index 2093a834ae5..91895c8748c 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -1149,6 +1149,8 @@ static constexpr RecordElement RecordsConfig[] = , {RECT_CONFIG, "proxy.config.url_remap.acl_behavior_policy", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-1]", RECA_NULL} , + {RECT_CONFIG, "proxy.config.virtualhost.filename", RECD_STRING, ts::filename::VIRTUALHOST, RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} + , //############################################################################## //# diff --git a/tests/gold_tests/autest-site/config_reload.test.ext b/tests/gold_tests/autest-site/config_reload.test.ext index 8a9f352223c..351c82009a1 100644 --- a/tests/gold_tests/autest-site/config_reload.test.ext +++ b/tests/gold_tests/autest-site/config_reload.test.ext @@ -146,6 +146,7 @@ def AddConfigReload( expect_absent_tasks=None, delay_start=None, description=None, + directives=None, ): """Trigger a config reload, block until completion, and validate the result. @@ -174,6 +175,8 @@ def AddConfigReload( Useful when the previous step writes a config file and CI may run fast enough that the filesystem timestamp hasn't changed. description: Custom TestRun description. + directives: Dict of reload directives passed with --directive, e.g. + {"virtualhost.id": "foo"}. Scopes the reload to the named handlers. Returns: The reload TestRun object (first test run). @@ -204,6 +207,10 @@ def AddConfigReload( cmd += " --force" if data is not None: cmd += f" --data {_shell_quote(data)}" + # --directive= keeps each pair attached to its own option, so a following + # option is never swallowed as a directive value. + for key, value in (directives or {}).items(): + cmd += f" --directive={_shell_quote(f'{key}={value}')}" tr.Processes.Default.Command = cmd diff --git a/tests/gold_tests/autest-site/trafficserver.test.ext b/tests/gold_tests/autest-site/trafficserver.test.ext index fa31324a14c..7047baa6ee0 100755 --- a/tests/gold_tests/autest-site/trafficserver.test.ext +++ b/tests/gold_tests/autest-site/trafficserver.test.ext @@ -320,6 +320,10 @@ def MakeATSProcess( tmpname = os.path.join(config_dir, fname) p.Disk.File(tmpname, id=make_id(fname), typename="ats:config") + fname = "virtualhost.yaml" + tmpname = os.path.join(config_dir, fname) + p.Disk.File(tmpname, id=make_id(fname), typename="ats:config") + # The big motivation in exposing this file is that we need to tell the traffic_ctl # where to find the socket to interact with the TS. traffic_ctl cannot rely only # in the build layout for unit test. diff --git a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py index 55d38103452..c9aa27392f2 100644 --- a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py +++ b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py @@ -42,6 +42,14 @@ 'proxy.config.diags.debug.tags': 'rpc|config', }) +# Used by Test 12: a single-entry reload of this id must find it on disk. +ts.Disk.virtualhost_yaml.AddLines([ + 'virtualhost:', + ' - id: myhost.example.com', + ' domains:', + ' - myhost.example.com', +]) + # ============================================================================ # Test 1: File-based reload (no configs parameter) # ============================================================================ @@ -424,30 +432,36 @@ def validate_directive_fileonly(resp: Response): tr.StillRunningAfter = ts # ============================================================================ -# Test 12: Reload directive for unregistered config (virtualhost) -# virtualhost is not registered yet — should get 6010. -# This is the intended use case once the virtualhost handler is registered. +# Test 12: Reload directive routes to registered handler (virtualhost) +# virtualhost is registered as FileAndRpc — the _reload directive should be +# accepted by the framework and forwarded to VirtualHost's handler, which +# schedules an inline reload and returns a task token. # ============================================================================ -tr = Test.AddTestRun("Reload directive for unregistered config (virtualhost)") +tr = Test.AddTestRun("Reload directive routed to registered virtualhost handler") tr.DelayStart = 2 tr.AddJsonRPCClientRequest(ts, Request.admin_config_reload(configs={"virtualhost": {"_reload": {"id": "myhost.example.com"}}})) -def validate_directive_unregistered(resp: Response): - '''virtualhost is not registered — rejected with 6010''' +def validate_directive_routed(resp: Response): + '''virtualhost is registered as FileAndRpc — the directive should be accepted and scheduled''' result = resp.result errors = result.get('errors', []) - if not errors: - return (False, f"Expected error for unregistered config, got: {result}") + if errors: + error_str = str(errors) + if '6010' in error_str or 'not registered' in error_str: + return (False, f"virtualhost should now be registered, got: {errors}") + return (False, f"Unexpected synchronous error: {errors}") - error_str = str(errors) - if '6010' in error_str or 'not registered' in error_str: - return (True, f"Directive for unregistered config rejected: {errors}") - return (False, f"Expected error 6010, got: {errors}") + tasks = result.get('tasks', []) + message = result.get('message', []) + if tasks or message: + return (True, f"Directive accepted and reload scheduled: tasks={tasks}, message={message}") + + return (False, f"Expected scheduled reload, got: {result}") -tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_directive_unregistered) +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_directive_routed) tr.StillRunningAfter = ts # ============================================================================ @@ -489,3 +503,436 @@ def validate_directive_mixed(resp: Response): tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_directive_mixed) tr.StillRunningAfter = ts + +# ============================================================================ +# Test 14: virtualhost config content over RPC is rejected +# virtualhost is FileAndRpc, but only '_reload' directives are supported — a +# supplied body must be refused instead of silently ignored. The handler reports +# the rejection through the reload task log, so the rejection is verified by +# querying the task status rather than by scanning diags.log. +# ============================================================================ +vhost_reject_token = "vhost-content-reject" + +tr = Test.AddTestRun("virtualhost rejects pushed config content") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest( + ts, + Request.admin_config_reload( + token=vhost_reject_token, configs={"virtualhost": [{ + "id": "pushed.example.com", + "domains": ["pushed.example.com"] + }]})) + + +def validate_content_rejected(resp: Response): + '''Content is accepted by the framework and rejected by the handler''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected synchronous error: {errors}") + + return (True, f"Content accepted by framework, handler expected to reject: {result}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_content_rejected) +tr.StillRunningAfter = ts + +# The handler's rejection lands in the reload task log — query it by token. +tr = Test.AddTestRun("virtualhost content rejection is reported in the reload task log") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest(ts, Request.get_reload_config_status(token=vhost_reject_token)) + + +def validate_content_rejection_logged(resp: Response): + '''The virtualhost subtask should be FAIL and carry the rejection message''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected error querying status: {errors}") + + expected = "virtualhost does not accept config content over rpc" + + def find_rejection(task_list): + for t in task_list: + for entry in t.get('logs', []): + if expected in entry.get('text', ''): + return t + found = find_rejection(t.get('sub_tasks', [])) + if found: + return found + return None + + tasks = result.get('tasks', []) + task = find_rejection(tasks) + + if task is None: + return (False, f"Pushed virtualhost content should be rejected, no such log in: {tasks}") + + status = task.get('status', '') + if status != 'fail': + return (False, f"Expected the rejecting task to be 'fail', got '{status}': {task}") + + return (True, f"Pushed virtualhost content rejected and logged: {task.get('description', '')}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_content_rejection_logged) +tr.StillRunningAfter = ts + +# ============================================================================ +# Test 15: single-entry reload of an id that is not in virtualhost.yaml +# This logs a core ERROR, so it runs against its own ATS instance whose diags +# expectations are replaced. The handler reports the failure through the reload +# task log, which is where an operator sees it, so that is asserted too. +# ============================================================================ +ts_unknown = Test.MakeATSProcess('ts-unknown-id') +ts_unknown.Disk.records_config.update({ + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'rpc|config', +}) +ts_unknown.Disk.virtualhost_yaml.AddLines( + [ + 'virtualhost:', + ' - id: present.example.com', + ' domains:', + ' - present.example.com', + ]) +ts_unknown.Disk.diags_log.Content = Testers.ContainsExpression( + "virtualhost with id 'absent.example.com' not found", "Reloading an unknown id should report it as not found") +ts_unknown.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", "Unknown id should not be fatal") + +vhost_unknown_id_token = "vhost-unknown-id" + +tr = Test.AddTestRun("Single-entry reload of an unknown virtualhost id") +tr.Processes.Default.StartBefore(ts_unknown) +tr.AddJsonRPCClientRequest( + ts_unknown, + Request.admin_config_reload(token=vhost_unknown_id_token, configs={"virtualhost": { + "_reload": { + "id": "absent.example.com" + } + }})) + + +def validate_unknown_id(resp: Response): + '''Accepted by the framework; the handler fails because the id is not on disk''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected synchronous error: {errors}") + + return (True, f"Directive accepted, handler expected to fail: {result}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_id) +tr.StillRunningAfter = ts_unknown + + +def find_failed_task_with(task_list, expected): + '''Depth-first search for a failed task whose log carries `expected`''' + for t in task_list: + for entry in t.get('logs', []): + if expected in entry.get('text', ''): + return t + found = find_failed_task_with(t.get('sub_tasks', []), expected) + if found: + return found + return None + + +# The not-found diagnostic must reach the operator who asked for the reload, not +# just diags.log — query the task log by token. +tr = Test.AddTestRun("Unknown virtualhost id is reported in the reload task log") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest(ts_unknown, Request.get_reload_config_status(token=vhost_unknown_id_token)) + + +def validate_unknown_id_logged(resp: Response): + '''The virtualhost subtask should be FAIL and name the missing id''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected error querying status: {errors}") + + expected = "virtualhost with id 'absent.example.com' not found" + tasks = result.get('tasks', []) + task = find_failed_task_with(tasks, expected) + + if task is None: + return (False, f"Expected '{expected}' in the reload task log, got: {tasks}") + + status = task.get('status', '') + if status != 'fail': + return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}") + + return (True, f"Unknown id reported over rpc: {task.get('description', '')}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_id_logged) +tr.StillRunningAfter = ts_unknown + +# ============================================================================ +# Test 16: single-entry reload with no virtualhost.yaml on disk +# A missing file is handled like a missing config, not a parse failure — it must +# not surface a yaml-cpp 'bad file' ERROR in diags.log. +# +# The startup path and the reload path report a missing file differently on +# purpose: startup warns and carries on with no entries, a single-entry reload +# fails. Asserting the reload-specific wording in the task log is what makes +# this test depend on the RPC — the startup warning is emitted before any +# request is sent, so asserting only that would pass with the handler removed. +# ============================================================================ +ts_missing = Test.MakeATSProcess('ts-missing-file') +ts_missing.Disk.records_config.update({ + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'rpc|config', +}) +# No virtualhost.yaml is written for this instance. +ts_missing.Disk.diags_log.Content = Testers.ContainsExpression( + "Virtualhost configuration .* doesn't exist, no virtualhost entries loaded", + "Startup with no virtualhost.yaml should warn and carry on") +ts_missing.Disk.diags_log.Content += Testers.ExcludesExpression( + "bad file", "Missing virtualhost.yaml should not surface a yaml-cpp load failure") +ts_missing.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", "A missing virtualhost.yaml should not be fatal") + +vhost_missing_file_token = "vhost-missing-file" + +tr = Test.AddTestRun("Single-entry reload with no virtualhost.yaml") +tr.Processes.Default.StartBefore(ts_missing) +tr.AddJsonRPCClientRequest( + ts_missing, + Request.admin_config_reload(token=vhost_missing_file_token, configs={"virtualhost": { + "_reload": { + "id": "myhost.example.com" + } + }})) + + +def validate_missing_file(resp: Response): + '''Accepted by the framework; the handler fails because the file is absent''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected synchronous error: {errors}") + + return (True, f"Directive accepted, handler expected to fail: {result}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file) +tr.StillRunningAfter = ts_missing + +tr = Test.AddTestRun("Missing virtualhost.yaml is reported in the reload task log") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest(ts_missing, Request.get_reload_config_status(token=vhost_missing_file_token)) + + +def validate_missing_file_logged(resp: Response): + '''The failure must be distinguishable from the benign startup warning''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected error querying status: {errors}") + + expected = "Cannot reload virtualhost entry 'myhost.example.com'" + tasks = result.get('tasks', []) + task = find_failed_task_with(tasks, expected) + + if task is None: + return (False, f"Expected '{expected}' in the reload task log, got: {tasks}") + + status = task.get('status', '') + if status != 'fail': + return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}") + + return (True, f"Missing file reported over rpc: {task.get('description', '')}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file_logged) +tr.StillRunningAfter = ts_missing + +# ============================================================================ +# Test 17: an empty '_reload' id is refused, not widened to a full reload +# The scoped request must not silently rebuild the whole table from disk, which +# would pick up every unrelated edit currently in the file. +# ============================================================================ +vhost_empty_id_token = "vhost-empty-id" + +tr = Test.AddTestRun("Single-entry reload with an empty virtualhost id") +tr.AddJsonRPCClientRequest( + ts, Request.admin_config_reload(token=vhost_empty_id_token, configs={"virtualhost": { + "_reload": { + "id": "" + } + }})) + + +def validate_empty_id(resp: Response): + '''Accepted by the framework; the handler refuses the directive''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected synchronous error: {errors}") + + return (True, f"Directive accepted, handler expected to refuse: {result}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_empty_id) +tr.StillRunningAfter = ts + +tr = Test.AddTestRun("Empty virtualhost id is refused in the reload task log") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest(ts, Request.get_reload_config_status(token=vhost_empty_id_token)) + + +def validate_empty_id_refused(resp: Response): + '''The subtask must FAIL and point at the way to ask for a full reload''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected error querying status: {errors}") + + expected = "must name an entry" + tasks = result.get('tasks', []) + task = find_failed_task_with(tasks, expected) + + if task is None: + return (False, f"Expected '{expected}' in the reload task log, got: {tasks}") + + status = task.get('status', '') + if status != 'fail': + return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}") + + return (True, f"Empty id refused: {task.get('description', '')}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_empty_id_refused) +tr.StillRunningAfter = ts + +# ============================================================================ +# Test 18: a full reload with no virtualhost.yaml on disk fails +# Startup tolerates an absent file; a reload must not, because publishing an +# empty config would drop every live per-domain remap table and still report +# success. Reuses the instance from Test 16, which has no virtualhost.yaml. +# ============================================================================ +ts_missing.Disk.diags_log.Content += Testers.ContainsExpression( + "Cannot reload virtualhost config", "A full reload with no virtualhost.yaml must not report success") + +vhost_missing_full_token = "vhost-missing-file-full" + +tr = Test.AddTestRun("Full virtualhost reload with no virtualhost.yaml") +tr.AddJsonRPCClientRequest( + ts_missing, Request.admin_config_reload(token=vhost_missing_full_token, configs={"virtualhost": { + "_reload": {} + }})) + + +def validate_missing_file_full(resp: Response): + '''Accepted by the framework; the handler fails because the file is absent''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected synchronous error: {errors}") + + return (True, f"Request accepted, handler expected to fail: {result}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file_full) +tr.StillRunningAfter = ts_missing + +tr = Test.AddTestRun("Full reload with no virtualhost.yaml is reported as a failure") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest(ts_missing, Request.get_reload_config_status(token=vhost_missing_full_token)) + + +def validate_missing_file_full_logged(resp: Response): + '''The whole-file reload must fail rather than publish an empty table''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected error querying status: {errors}") + + expected = "Cannot reload virtualhost config" + tasks = result.get('tasks', []) + task = find_failed_task_with(tasks, expected) + + if task is None: + return (False, f"Expected '{expected}' in the reload task log, got: {tasks}") + + status = task.get('status', '') + if status != 'fail': + return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}") + + return (True, f"Missing file refused on a full reload: {task.get('description', '')}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file_full_logged) +tr.StillRunningAfter = ts_missing + +# ============================================================================ +# Test 19: an unrecognized '_reload' key is refused, not widened to a full reload +# A mistyped key like 'ID' must not rebuild the whole table from disk. +# ============================================================================ +vhost_unknown_key_token = "vhost-unknown-key" + +tr = Test.AddTestRun("Reload directive with an unrecognized virtualhost key") +tr.AddJsonRPCClientRequest( + ts, + Request.admin_config_reload(token=vhost_unknown_key_token, configs={"virtualhost": { + "_reload": { + "ID": "myhost.example.com" + } + }})) + + +def validate_unknown_directive(resp: Response): + '''Accepted by the framework; the handler refuses the directive''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected synchronous error: {errors}") + + return (True, f"Directive accepted, handler expected to refuse: {result}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_directive) +tr.StillRunningAfter = ts + +tr = Test.AddTestRun("Unrecognized virtualhost key is refused in the reload task log") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest(ts, Request.get_reload_config_status(token=vhost_unknown_key_token)) + + +def validate_unknown_directive_refused(resp: Response): + '''The subtask must FAIL and name the offending key''' + result = resp.result + errors = result.get('errors', []) + + if errors: + return (False, f"Unexpected error querying status: {errors}") + + expected = "directive 'ID' is not supported" + tasks = result.get('tasks', []) + task = find_failed_task_with(tasks, expected) + + if task is None: + return (False, f"Expected '{expected}' in the reload task log, got: {tasks}") + + status = task.get('status', '') + if status != 'fail': + return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}") + + return (True, f"Unknown key refused: {task.get('description', '')}") + + +tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_directive_refused) +tr.StillRunningAfter = ts diff --git a/tests/gold_tests/remap/virtualhost_remap.test.py b/tests/gold_tests/remap/virtualhost_remap.test.py new file mode 100644 index 00000000000..79871fd36b2 --- /dev/null +++ b/tests/gold_tests/remap/virtualhost_remap.test.py @@ -0,0 +1,255 @@ +''' +Verify that per-domain remap tables in virtualhost.yaml are applied to requests. +''' +# 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 = ''' +Verify virtualhost.yaml domain resolution and per-domain remap rules on the request path. +''' + +import os + +Test.ContinueOnFail = True +Test.testName = 'virtualhost_remap' + +ts = Test.MakeATSProcess("ts") + +# The origin is keyed on the request path only (the default lookup key), so each +# remap rule can be given a private target path. Which rule won is then decided +# by which body comes back, not merely by getting a 200. +server = Test.MakeOriginServer("server") + + +def add_origin_response(path: str, body: str) -> None: + """Register an origin response for `path` carrying a rule-specific body.""" + request_header = { + "headers": f"GET {path} HTTP/1.1\r\nHost: origin.example.com\r\n\r\n", + "timestamp": "1469733493.993", + "body": "" + } + response_header = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "timestamp": "1469733493.993", "body": body} + server.addResponse("sessionfile.log", request_header, response_header) + + +# One target path per remap rule that could plausibly fire. The bodies are chosen +# so that no expected body is a substring of another. +add_origin_response("/vhost-exact-domain/", "hit:vhost-exact-domain") +add_origin_response("/vhost-deep-wildcard/", "hit:vhost-deep-wildcard") +add_origin_response("/vhost-wide-wildcard/", "hit:vhost-wide-wildcard") +add_origin_response("/vhost-wildcard-precedence/", "hit:vhost-wildcard-precedence") +add_origin_response("/vhost-exact-precedence/", "hit:vhost-exact-precedence") +add_origin_response("/vhost-fallback-rule/", "hit:vhost-fallback-rule") +add_origin_response("/global-fallback/other/", "hit:global-fallback") +add_origin_response("/global-plain/", "hit:global-plain") +# Only reachable if the rejected reload below is wrongly published. +add_origin_response("/vhost-conflict/", "hit:vhost-conflict") + +ts.Disk.records_config.update({ + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'virtualhost|url_rewrite', +}) + +# The refused reload at the end of this test logs the conflict as an ERROR, which +# replaces the default diags expectations. +ts.Disk.diags_log.Content = Testers.ContainsExpression( + "is already claimed by virtualhost 'exact-only'", "The conflicting reload should name the virtualhost holding the domain") +ts.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", "A refused reload must not be fatal") + +origin = f'127.0.0.1:{server.Variables.Port}' + +# Global table, in remap.config (legacy) format. The virtualhost tables below are +# always YAML, so this also covers the mixed case. +ts.Disk.remap_config.AddLines( + [ + f'map http://fallback.example.net/ http://{origin}/global-fallback/', + f'map http://none.example.net/ http://{origin}/global-plain/', + ]) + +vhost_config_lines = [ + 'virtualhost:', + # Plain exact-domain match. No wildcard in this config matches .example.org. + ' - id: exact-only', + ' domains:', + ' - exact.example.org', + ' remap:', + ' - type: map', + ' from:', + ' url: http://exact.example.org/', + ' to:', + f' url: http://{origin}/vhost-exact-domain/', + # x.deep.example.com matches both this wildcard and the wider one below. + # The longest (most specific) suffix must win. + ' - id: deep-wildcard', + ' domains:', + ' - "*.deep.example.com"', + ' remap:', + ' - type: map', + ' from:', + ' url: http://x.deep.example.com/', + ' to:', + f' url: http://{origin}/vhost-deep-wildcard/', + ' - id: wide-wildcard', + ' domains:', + ' - "*.example.com"', + ' remap:', + ' - type: map', + ' from:', + ' url: http://x.deep.example.com/', + ' to:', + f' url: http://{origin}/vhost-wide-wildcard/', + ' - type: map', + ' from:', + ' url: http://precedence.example.com/', + ' to:', + f' url: http://{origin}/vhost-wildcard-precedence/', + # precedence.example.com is claimed exactly here and by the wildcard + # above. The exact domain must win. + ' - id: exact-precedence', + ' domains:', + ' - precedence.example.com', + ' remap:', + ' - type: map', + ' from:', + ' url: http://precedence.example.com/', + ' to:', + f' url: http://{origin}/vhost-exact-precedence/', + # This virtualhost resolves for fallback.example.net but its only rule + # covers a different path, so requests elsewhere must fall back to the + # global table. + ' - id: path-miss', + ' domains:', + ' - fallback.example.net', + ' remap:', + ' - type: map', + ' from:', + ' url: http://fallback.example.net/only-here/', + ' to:', + f' url: http://{origin}/vhost-fallback-rule/', +] + +ts.Disk.virtualhost_yaml.AddLines(vhost_config_lines) + + +def add_request(name: str, host: str, path: str, expected: str, not_expected: str = "") -> 'TestRun': + """Send one request through the proxy and assert which remap rule served it. + + :param name: Test run name. + :param host: Host header, which selects the virtualhost. + :param path: Request path. + :param expected: Body of the rule that must have won. + :param not_expected: Body of the rule that must have lost, if any. + """ + tr = Test.AddTestRun(name) + tr.MakeCurlCommand(f'-s -H"Host: {host}" http://127.0.0.1:{ts.Variables.port}{path} --verbose', ts=ts) + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = Testers.ContainsExpression(expected, f"{host}{path} should be served by {expected}") + if not_expected: + tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression( + not_expected, f"{host}{path} must not be served by {not_expected}") + tr.StillRunningAfter = ts + tr.StillRunningAfter += server + return tr + + +# The first run starts the processes. +tr = add_request( + "Exact virtualhost domain uses its own remap table", "exact.example.org", "/", "hit:vhost-exact-domain", "hit:global-plain") +tr.Processes.Default.StartBefore(server) +tr.Processes.Default.StartBefore(ts) + +add_request("Longest wildcard suffix wins", "x.deep.example.com", "/", "hit:vhost-deep-wildcard", "hit:vhost-wide-wildcard") + +add_request( + "Exact domain takes precedence over a wildcard", "precedence.example.com", "/", "hit:vhost-exact-precedence", + "hit:vhost-wildcard-precedence") + +add_request( + "A virtualhost whose rules do not match falls back to the global table", "fallback.example.net", "/other/", + "hit:global-fallback", "hit:vhost-fallback-rule") + +add_request("A host with no virtualhost entry uses the global table", "none.example.net", "/", "hit:global-plain") + +# ============================================================================ +# A reload that is refused must leave the previous routing table serving. +# +# deep-wildcard is rewritten to also claim exact.example.org, which exact-only +# already holds. That conflict is detected by set_entry(), inside the critical +# section and after the copy of the live config has already dropped the old +# deep-wildcard entry — so this exercises the one failure path that runs after +# the read-copy-modify begins. Nothing may be published, and both the entry +# that was reloaded and the entry it collided with must still serve their +# original rules. +# ============================================================================ +vhost_config_path = os.path.join(ts.Variables.CONFIGDIR, 'virtualhost.yaml') + +conflicting_config_lines = [ + 'virtualhost:', + ' - id: exact-only', + ' domains:', + ' - exact.example.org', + ' remap:', + ' - type: map', + ' from:', + ' url: http://exact.example.org/', + ' to:', + f' url: http://{origin}/vhost-exact-domain/', + ' - id: deep-wildcard', + ' domains:', + ' - "*.deep.example.com"', + # Already claimed by exact-only above. + ' - exact.example.org', + ' remap:', + ' - type: map', + ' from:', + ' url: http://x.deep.example.com/', + ' to:', + f' url: http://{origin}/vhost-conflict/', + ' - type: map', + ' from:', + ' url: http://exact.example.org/', + ' to:', + f' url: http://{origin}/vhost-conflict/', +] + + +def write_conflicting_config() -> None: + """Replace virtualhost.yaml with a version whose deep-wildcard entry steals a claimed domain.""" + with open(vhost_config_path, 'w') as f: + f.write("\n".join(conflicting_config_lines) + "\n") + + +tr = Test.AddTestRun("Rewrite virtualhost.yaml so deep-wildcard claims a domain exact-only holds") +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.Command = 'echo "rewrite virtualhost.yaml with a domain conflict"' +tr.Processes.Default.Setup.Lambda(lambda: write_conflicting_config()) +tr.StillRunningAfter = ts + +Test.AddConfigReload( + ts, + expect="fail", + directives={"virtualhost.id": "deep-wildcard"}, + expect_tasks={"virtualhost": "fail"}, + delay_start=2, + description="Single-entry reload with a claimed domain is refused") + +add_request( + "The refused reload leaves the reloaded entry serving its old rules", "x.deep.example.com", "/", "hit:vhost-deep-wildcard", + "hit:vhost-conflict") + +add_request( + "The refused reload leaves the entry it collided with serving", "exact.example.org", "/", "hit:vhost-exact-domain", + "hit:vhost-conflict")