From b5aaa60ad57e4a11c0e66af73f71b9ad2fb78995 Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Mon, 20 Apr 2026 16:41:22 -0700 Subject: [PATCH 1/9] init vhost remap --- configs/virtualhost.yaml.default | 27 ++ doc/admin-guide/files/index.en.rst | 4 + doc/admin-guide/files/records.yaml.en.rst | 7 + doc/admin-guide/files/virtualhost.yaml.en.rst | 214 +++++++++ include/proxy/VirtualHost.h | 102 ++++ include/proxy/http/HttpSM.h | 3 + include/proxy/http/remap/RemapYamlConfig.h | 4 + include/proxy/http/remap/UrlRewrite.h | 4 +- include/tscore/Filenames.h | 1 + src/proxy/CMakeLists.txt | 1 + src/proxy/ReverseProxy.cc | 3 + src/proxy/VirtualHost.cc | 450 ++++++++++++++++++ src/proxy/http/HttpSM.cc | 57 +++ src/proxy/http/remap/RemapYamlConfig.cc | 58 +++ src/proxy/http/remap/UrlRewrite.cc | 19 +- src/records/RecordsConfig.cc | 2 + src/traffic_ctl/CtrlCommands.cc | 10 + src/traffic_ctl/traffic_ctl.cc | 4 +- 18 files changed, 965 insertions(+), 5 deletions(-) create mode 100644 configs/virtualhost.yaml.default create mode 100644 doc/admin-guide/files/virtualhost.yaml.en.rst create mode 100644 include/proxy/VirtualHost.h create mode 100644 src/proxy/VirtualHost.cc diff --git a/configs/virtualhost.yaml.default b/configs/virtualhost.yaml.default new file mode 100644 index 00000000000..1731cb2b08f --- /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/ \ No newline at end of file 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 25c4bec22ef..699e1e2cebd 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -6110,3 +6110,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. \ No newline at end of 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..14c6602596c --- /dev/null +++ b/doc/admin-guide/files/virtualhost.yaml.en.rst @@ -0,0 +1,214 @@ + +.. 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.config`, +: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 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 virtual host + config defined first has precedence. + + 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. If multiple entries could match, ATS uses the first matching +entry defined in :file:`virtualhost.yaml`. + + +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: + +:: + + $ traffic_ctl config reload --virtualhost + +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 --virtualhost foo + ┌ Virtualhost: foo + └┬ Reload status: ok + ├ Message: Virtualhost successfully reloaded + + +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://example.com + to: + url: http://origin.example.com/ + +This rules translates 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` \ No newline at end of file diff --git a/include/proxy/VirtualHost.h b/include/proxy/VirtualHost.h new file mode 100644 index 00000000000..dd84c75a707 --- /dev/null +++ b/include/proxy/VirtualHost.h @@ -0,0 +1,102 @@ +/** @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 "iocore/eventsystem/ConfigProcessor.h" +#include "proxy/http/remap/UrlRewrite.h" +#include "tscore/Ptr.h" + +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; + Ptr remap_table; + + Entry *acquire() const; + void release() const; + std::string get_id() const; + }; + + bool load(); + bool set_entry(std::string_view id, Ptr &entry); + static bool load_entry(std::string_view id, Ptr &entry); + Ptr find_by_id(std::string_view id) const; + Ptr find_by_domain(std::string_view domain) const; + +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; +}; + +class VirtualHost +{ +public: + using scoped_config = ConfigProcessor::scoped_config; + + static void startup(); + static int reconfigure(); + static int reconfigure(std::string_view id); + static VirtualHostConfig *acquire(); + static void release(VirtualHostConfig *config); + +private: + static int config_callback(const char *, RecDataT, RecData, void *); + static int _configid; +}; + +struct VirtualHostConfigContinuation : public Continuation { + VirtualHostConfigContinuation() : Continuation(nullptr) { SET_HANDLER(&VirtualHostConfigContinuation::reconfigure); } + + int + reconfigure(int /* event ATS_UNUSED */, Event * /* e ATS_UNUSED */) + { + VirtualHost::reconfigure(); + delete this; + return EVENT_DONE; + } +}; diff --git a/include/proxy/http/HttpSM.h b/include/proxy/http/HttpSM.h index c2128eeca20..eb4b6ef18c6 100644 --- a/include/proxy/http/HttpSM.h +++ b/include/proxy/http/HttpSM.h @@ -46,6 +46,7 @@ #include "api/InkAPIInternal.h" #include "proxy/ProxyTransaction.h" #include "proxy/hdrs/HdrUtils.h" +#include "proxy/VirtualHost.h" // inknet #include "proxy/http/PreWarmManager.h" @@ -313,6 +314,7 @@ 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; + VirtualHostConfig::Entry *m_virtualhost_entry = nullptr; History history; NetVConnection * @@ -370,6 +372,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/RemapYamlConfig.h b/include/proxy/http/remap/RemapYamlConfig.h index a93ee9d8b10..3cb93922a07 100644 --- a/include/proxy/http/remap/RemapYamlConfig.h +++ b/include/proxy/http/remap/RemapYamlConfig.h @@ -72,3 +72,7 @@ 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 = {}); + +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..06f416a3999 100644 --- a/include/proxy/http/remap/UrlRewrite.h +++ b/include/proxy/http/remap/UrlRewrite.h @@ -80,13 +80,15 @@ class UrlRewrite */ bool load(ConfigContext ctx = {}); + bool load_table(const std::string &config_file_path, YAML::Node const *remap_node); + /** 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..b46088f2108 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 { @@ -153,6 +154,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; } diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc new file mode 100644 index 00000000000..1919aeafad9 --- /dev/null +++ b/src/proxy/VirtualHost.cc @@ -0,0 +1,450 @@ +/** @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 "proxy/VirtualHost.h" +#include "mgmt/config/ConfigRegistry.h" +#include "records/RecCore.h" +#include "tscore/Filenames.h" +#include "tsutil/Convert.h" + +namespace +{ +DbgCtl dbg_ctl_virtualhost("virtualhost"); +} + +int VirtualHost::_configid = 0; + +VirtualHostConfig::Entry * +VirtualHostConfig::Entry::acquire() const +{ + auto *self = const_cast(this); + if (self) { + self->refcount_inc(); + } + return self; +} + +void +VirtualHostConfig::Entry::release() const +{ + auto *self = const_cast(this); + if (self && self->refcount_dec() == 0) { + self->free(); + } +} + +std::string +VirtualHostConfig::Entry::get_id() const +{ + return id; +} + +std::set valid_vhost_keys = {"id", "domains", "remap"}; + +template <> struct YAML::convert { + static bool + decode(const YAML::Node &node, VirtualHostConfig::Entry &item) + { + for (const auto &elem : node) { + if (std::none_of(valid_vhost_keys.begin(), valid_vhost_keys.end(), + [&elem](const std::string &s) { return s == elem.first.as(); })) { + Warning("unsupported key '%s' in VirtualHost config", elem.first.as().c_str()); + } + } + + if (!node["id"]) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide `id`"); + return false; + } + item.id = node["id"].as(); + + auto domains = node["domains"]; + if (!domains || !domains.IsSequence() || domains.size() == 0) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide at least one domain in `domains` sequence"); + return false; + } + item.exact_domains.clear(); + item.wildcard_domains.clear(); + + for (const auto &it : domains) { + auto domain_entry = it.as(); + if (domain_entry.empty()) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry can't have empty domain entry"); + 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] == '*') { + const char *subdomain = index(domain, '*'); + if (subdomain && subdomain[1] == '.') { + item.wildcard_domains.push_back(subdomain + 2); + } else { + Dbg(dbg_ctl_virtualhost, "Virtual host wildcard entry must have '*.[domain]' format"); + } + } else { + item.exact_domains.push_back(domain); + } + } + + if (item.exact_domains.empty() && item.wildcard_domains.empty()) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry must have at least one domain defined"); + return false; + } + + return true; + } +}; + +bool +build_virtualhost_entry(YAML::Node const &node, Ptr &entry) +{ + entry.clear(); + Ptr vhost = make_ptr(new VirtualHostConfig::Entry); + auto &conf = *vhost; + try { + if (!YAML::convert::decode(node, conf)) { + return false; + } + } catch (YAML::Exception const &ex) { + Dbg(dbg_ctl_virtualhost, "Failed to parse virtualhost entry"); + return false; + } + + // Build UrlRewrite table for remap rules + auto remap_node = node["remap"]; + if (remap_node) { + auto table = std::make_unique(); + if (!table->load_table(conf.id, &remap_node)) { + Dbg(dbg_ctl_virtualhost, "Failed to load remap rules for virtualhost entry"); + return false; + } + conf.remap_table = make_ptr(table.release()); + } + entry = std::move(vhost); + return true; +} + +bool +VirtualHostConfig::load() +{ + _entries.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) { + Warning("Virtualhost configuration '%s' doesn't exist", config_path.c_str()); + return true; + } + + try { + YAML::Node config = YAML::LoadFile(config_path); + if (config.IsNull()) { + Dbg(dbg_ctl_virtualhost, "Empty virtualhost config: %s", config_path.c_str()); + return true; + } + + config = config["virtualhost"]; + if (config.IsNull() || !config.IsSequence()) { + Dbg(dbg_ctl_virtualhost, "Expected toplevel 'virtualhost' key to be a sequence"); + return false; + } + + for (auto const &node : config) { + Ptr entry; + if (!build_virtualhost_entry(node, entry)) { + return false; + } + + std::string vhost_id{entry->id}; + if (_entries.contains(vhost_id)) { + Dbg(dbg_ctl_virtualhost, "Duplicate virtualhost id: %s", vhost_id.c_str()); + return false; + } + + for (auto const &domain : entry->exact_domains) { + if (_exact_domains_to_id.contains(domain)) { + Dbg(dbg_ctl_virtualhost, "Exact domain (%s) already in another virtualhost config", 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)) { + Dbg(dbg_ctl_virtualhost, "Wildcard domain (%s) already in another virtualhost config", 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) { + Dbg(dbg_ctl_virtualhost, "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) +{ + entry.clear(); + std::string config_path = RecConfigReadConfigPath("proxy.config.virtualhost.filename", ts::filename::VIRTUALHOST); + + try { + YAML::Node config = YAML::LoadFile(config_path); + if (config.IsNull()) { + Dbg(dbg_ctl_virtualhost, "Empty virtualhost config: %s", config_path.c_str()); + return true; + } + + config = config["virtualhost"]; + if (config.IsNull() || !config.IsSequence()) { + Dbg(dbg_ctl_virtualhost, "Expected toplevel 'virtualhost' key to be a sequence"); + 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)) { + return false; + } + entry = std::move(vhost_entry); + return true; + } + + } catch (std::exception &ex) { + Dbg(dbg_ctl_virtualhost, "Failed to load virtualhost entry (%s) in %s: %s", id.data(), config_path.c_str(), ex.what()); + return false; + } + Dbg(dbg_ctl_virtualhost, "Virtualhost with id (%s) not found", id.data()); + return true; +} + +bool +VirtualHostConfig::set_entry(std::string_view id, Ptr &entry) +{ + 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)) { + Dbg(dbg_ctl_virtualhost, "Exact domain (%s) already in another virtualhost config", 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)) { + Dbg(dbg_ctl_virtualhost, "Wildcard domain (%s) already in another virtualhost config", domain_suffix.c_str()); + return false; + } + _wildcard_domains_to_id.emplace(domain_suffix, vhost_id); + } + + _entries.emplace(vhost_id, std::move(entry)); + } + return true; +} + +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(); +} + +void +VirtualHost::startup() +{ + if (!reconfigure()) { + Fatal("failed to load %s", ts::filename::VIRTUALHOST); + } + RecRegisterConfigUpdateCb("proxy.config.virtualhost.filename", &VirtualHost::config_callback, nullptr); + + config::ConfigRegistry::Get_Instance().register_config("virtualhost", // registry key + ts::filename::VIRTUALHOST, // default filename + "proxy.config.virtualhost.filename", // record holding the filename + [](ConfigContext ctx) { + ctx.in_progress(); + auto yaml = ctx.supplied_yaml(); + + // RPC-supplied scalar = single entry reload by ID + if (yaml && yaml.IsScalar()) { + std::string id = yaml.as(); + if (VirtualHost::reconfigure(id)) { + ctx.complete("Reloaded virtualhost entry: " + id); + } else { + ctx.fail("Failed to reload virtualhost entry: " + id); + } + return; + } + + // Full reload (file-based or no supplied content) + if (VirtualHost::reconfigure()) { + ctx.complete("Finished loading virtualhost config"); + } else { + ctx.fail("Failed to load virtualhost config"); + } + }, + config::ConfigSource::FileAndRpc, // supports RPC content + {"proxy.config.virtualhost.filename"}); // trigger records +} + +int +VirtualHost::reconfigure() +{ + Note("%s loading ...", ts::filename::VIRTUALHOST); + auto config = std::make_unique(); + + if (!config->load()) { + Error("%s failed to load", ts::filename::VIRTUALHOST); + return 0; + } + + _configid = configProcessor.set(_configid, config.release()); + + Note("%s finished loading", ts::filename::VIRTUALHOST); + return 1; +} + +int +VirtualHost::reconfigure(std::string_view id) +{ + VirtualHost::scoped_config vhost_config; + Dbg(dbg_ctl_virtualhost, "Reconfiguring virtualhost entry: %s", id.data()); + // Reconfigure all vhosts if id not specified + if (id.empty()) { + Dbg(dbg_ctl_virtualhost, "No virtualhost specified, reconfiguring all entries"); + return reconfigure(); + } + + Ptr entry; + if (!VirtualHostConfig::load_entry(id, entry)) { + return 0; + } + + std::unique_ptr config; + if (vhost_config) { + config = std::make_unique(*vhost_config); + } else { + config = std::make_unique(); + } + + if (!config->set_entry(id, entry)) { + return 0; + } + _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); + } +} + +int +VirtualHost::config_callback(const char *, RecDataT, RecData, void *) +{ + eventProcessor.schedule_imm(new VirtualHostConfigContinuation, ET_TASK); + return 0; +} diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 0d54ad376d7..f74a9228d62 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -281,6 +281,11 @@ HttpSM::HttpSM() : Continuation(nullptr), vc_table(this) {} HttpSM::~HttpSM() { + if (m_virtualhost_entry) { + m_virtualhost_entry->release(); + m_virtualhost_entry = nullptr; + } + http_parser_clear(&http_parser); // coverity[exn_spec_violation] - release() only does ref counting and delete on POD types @@ -4681,13 +4686,65 @@ 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()); + // Explicitly acquire() since HttpSM holds raw pointer + m_virtualhost_entry = vhost_entry->acquire(); + } +} + 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 + bool virtualhost_remap = false; + if (m_virtualhost_entry && m_virtualhost_entry->remap_table) { + UrlRewrite *vhost_table = m_virtualhost_entry->remap_table->acquire(); + if (vhost_table) { + // If already acquired, release ref + if (vhost_table == m_remap) { + vhost_table->release(); + } else { + if (m_remap) { + m_remap->release(); + } + m_remap = vhost_table; + } + SMDbg(dbg_ctl_url_rewrite, "Using virtualhost remap table: %s", m_virtualhost_entry->get_id().c_str()); + virtualhost_remap = true; + } + } + 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"); + if (m_remap) { + m_remap->release(); + } + m_remap = rewrite_table.load()->acquire(); + ret = remapProcessor.setup_for_remap(&t_state, m_remap); + } + check_sni_host(); // Depending on a variety of factors the HOST field may or may not have been promoted to the diff --git a/src/proxy/http/remap/RemapYamlConfig.cc b/src/proxy/http/remap/RemapYamlConfig.cc index 7a441434463..13b0c297ed4 100644 --- a/src/proxy/http/remap/RemapYamlConfig.cc +++ b/src/proxy/http/remap/RemapYamlConfig.cc @@ -1026,6 +1026,47 @@ 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)) { + 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()) { + Error("Failed to parse remap rule"); + return false; + } + } + + IpAllow::enableAcceptCheck(bti->accept_check_p); + + Dbg(dbg_ctl_remap_yaml, "Successfully parsed inline remap YAML rules"); + return true; + + } catch (YAML::Exception &ex) { + Error("YAML parsing error in inline remap rules: %s", ex.what()); + } catch (std::exception &ex) { + 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 +1087,20 @@ remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx) return status; } + +bool +remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite) +{ + BUILD_TABLE_INFO bti; + + rewrite->pluginFactory.indicatePreReload(); + + bti.rewrite = rewrite; + bool status = remap_parse_yaml_bti(remap_node, &bti); + + rewrite->pluginFactory.indicatePostReload(status); + + bti.clear_acl_rules_list(); + + return status; +} diff --git a/src/proxy/http/remap/UrlRewrite.cc b/src/proxy/http/remap/UrlRewrite.cc index 6cbef75e5e3..6527b52a7a4 100644 --- a/src/proxy/http/remap/UrlRewrite.cc +++ b/src/proxy/http/remap/UrlRewrite.cc @@ -95,6 +95,15 @@ UrlRewrite::load(ConfigContext ctx) return false; } } + return load_table(std::string(config_file_path.get()), nullptr); +} + +bool +UrlRewrite::load_table(const std::string &config_file_path, YAML::Node const *remap_node) +{ + if (remap_node) { + this->_remap_yaml = true; + } this->ts_name = nullptr; if (auto rec_str{RecGetRecordStringAlloc("proxy.config.proxy_name")}; rec_str) { @@ -143,7 +152,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(config_file_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); @@ -817,7 +826,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()); @@ -838,7 +847,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 f011c0ccd74..421cd7cbb4d 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -1145,6 +1145,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/src/traffic_ctl/CtrlCommands.cc b/src/traffic_ctl/CtrlCommands.cc index 88d29637ece..7e00c86db6e 100644 --- a/src/traffic_ctl/CtrlCommands.cc +++ b/src/traffic_ctl/CtrlCommands.cc @@ -605,6 +605,16 @@ ConfigCommand::config_reload() return; } } + // If --virtualhost is set, inject the virtualhost id into the configs + // so the server-side handler receives it as a scalar value for + // single-entry reload. + auto vhost_arg = get_parsed_arguments()->get("virtualhost"); + if (vhost_arg) { + std::string vhost_id = vhost_arg.value(); + if (!vhost_id.empty()) { + configs["virtualhost"] = vhost_id; + } + } using ConfigError = config::reload::errors::ConfigReloadError; diff --git a/src/traffic_ctl/traffic_ctl.cc b/src/traffic_ctl/traffic_ctl.cc index 1bda6e8521d..5296b74a459 100644 --- a/src/traffic_ctl/traffic_ctl.cc +++ b/src/traffic_ctl/traffic_ctl.cc @@ -176,7 +176,9 @@ main([[maybe_unused]] int argc, const char **argv) "Maximum time to wait for reload completion (used with --monitor). " "Accepts duration units: 30s, 1m, 500ms, etc. 0 means no timeout", "", 1, "0") - .with_required("--monitor"); + .with_required("--monitor") + // Include virtualhost option to only reload specified entry + .add_option("--virtualhost", "", "Reload only the specific virtual host entry by id", "", 1, ""); config_command.add_command("status", "Check the configuration status", [&]() { command->execute(); }) .add_option("--token", "-t", "Configuration token to check status.", "", 1, "") From c3d2273ce9d1416047b382518681caf458bfba39 Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Wed, 22 Apr 2026 10:47:04 -0700 Subject: [PATCH 2/9] Update doc from ssl_multicert.config to .yaml --- doc/admin-guide/files/virtualhost.yaml.en.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/admin-guide/files/virtualhost.yaml.en.rst b/doc/admin-guide/files/virtualhost.yaml.en.rst index 14c6602596c..b9dab213963 100644 --- a/doc/admin-guide/files/virtualhost.yaml.en.rst +++ b/doc/admin-guide/files/virtualhost.yaml.en.rst @@ -29,7 +29,7 @@ Virtual host remap rules override global :file:`remap.yaml` rules but remain ful 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.config`, +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 From 3f9f7633b4da5a4d6a61f35da33a770a692fca8c Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Tue, 19 May 2026 09:30:12 -0700 Subject: [PATCH 3/9] Use new _reload directive --- include/proxy/http/remap/UrlRewrite.h | 2 +- src/proxy/VirtualHost.cc | 8 ++++---- src/proxy/http/remap/RemapYamlConfig.cc | 12 ++++++------ src/proxy/http/remap/UrlRewrite.cc | 4 ++-- src/traffic_ctl/CtrlCommands.cc | 10 ---------- src/traffic_ctl/traffic_ctl.cc | 4 +--- 6 files changed, 14 insertions(+), 26 deletions(-) diff --git a/include/proxy/http/remap/UrlRewrite.h b/include/proxy/http/remap/UrlRewrite.h index 06f416a3999..b705d496e5b 100644 --- a/include/proxy/http/remap/UrlRewrite.h +++ b/include/proxy/http/remap/UrlRewrite.h @@ -80,7 +80,7 @@ class UrlRewrite */ bool load(ConfigContext ctx = {}); - bool load_table(const std::string &config_file_path, YAML::Node const *remap_node); + bool load_table(const std::string &config_file_path, YAML::Node const *remap_node, ConfigContext ctx = {}); /** Build the internal url write tables. * diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc index 1919aeafad9..b5ece25cd82 100644 --- a/src/proxy/VirtualHost.cc +++ b/src/proxy/VirtualHost.cc @@ -357,11 +357,11 @@ VirtualHost::startup() "proxy.config.virtualhost.filename", // record holding the filename [](ConfigContext ctx) { ctx.in_progress(); - auto yaml = ctx.supplied_yaml(); - // RPC-supplied scalar = single entry reload by ID - if (yaml && yaml.IsScalar()) { - std::string id = yaml.as(); + // Single-entry reload requested via -D virtualhost.id= + if (auto directives = ctx.reload_directives(); + directives && directives["id"]) { + std::string id = directives["id"].as(); if (VirtualHost::reconfigure(id)) { ctx.complete("Reloaded virtualhost entry: " + id); } else { diff --git a/src/proxy/http/remap/RemapYamlConfig.cc b/src/proxy/http/remap/RemapYamlConfig.cc index 13b0c297ed4..0a313ee1786 100644 --- a/src/proxy/http/remap/RemapYamlConfig.cc +++ b/src/proxy/http/remap/RemapYamlConfig.cc @@ -1039,7 +1039,7 @@ remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, Config ACLBehaviorPolicy behavior_policy = ACLBehaviorPolicy::ACL_BEHAVIOR_LEGACY; if (!UrlRewrite::get_acl_behavior_policy(behavior_policy)) { - Warning("Failed to get ACL matching policy."); + CfgLoadLog(ctx, DL_Warning, "Failed to get ACL matching policy."); return false; } bti->behavior_policy = behavior_policy; @@ -1049,7 +1049,7 @@ remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, Config auto errata = parse_yaml_remap_rule(rule, bti); if (!errata.is_ok()) { - Error("Failed to parse remap rule"); + CfgLoadLog(ctx, DL_Error, "Failed to parse remap rule"); return false; } } @@ -1060,9 +1060,9 @@ remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, Config return true; } catch (YAML::Exception &ex) { - Error("YAML parsing error in inline remap rules: %s", ex.what()); + CfgLoadLog(ctx, DL_Error, "YAML parsing error in inline remap rules: %s", ex.what()); } catch (std::exception &ex) { - Error("Exception parsing inline remap YAML rules: %s", ex.what()); + CfgLoadLog(ctx, DL_Error, "Exception parsing inline remap YAML rules: %s", ex.what()); } return false; } @@ -1089,14 +1089,14 @@ remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx) } bool -remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite) +remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite, ConfigContext ctx) { BUILD_TABLE_INFO bti; rewrite->pluginFactory.indicatePreReload(); bti.rewrite = rewrite; - bool status = remap_parse_yaml_bti(remap_node, &bti); + bool status = remap_parse_yaml_bti(remap_node, &bti, ctx); rewrite->pluginFactory.indicatePostReload(status); diff --git a/src/proxy/http/remap/UrlRewrite.cc b/src/proxy/http/remap/UrlRewrite.cc index 6527b52a7a4..a203041e90c 100644 --- a/src/proxy/http/remap/UrlRewrite.cc +++ b/src/proxy/http/remap/UrlRewrite.cc @@ -95,11 +95,11 @@ UrlRewrite::load(ConfigContext ctx) return false; } } - return load_table(std::string(config_file_path.get()), nullptr); + return load_table(std::string(config_file_path.get()), nullptr, ctx); } bool -UrlRewrite::load_table(const std::string &config_file_path, YAML::Node const *remap_node) +UrlRewrite::load_table(const std::string &config_file_path, YAML::Node const *remap_node, ConfigContext ctx) { if (remap_node) { this->_remap_yaml = true; diff --git a/src/traffic_ctl/CtrlCommands.cc b/src/traffic_ctl/CtrlCommands.cc index 7e00c86db6e..88d29637ece 100644 --- a/src/traffic_ctl/CtrlCommands.cc +++ b/src/traffic_ctl/CtrlCommands.cc @@ -605,16 +605,6 @@ ConfigCommand::config_reload() return; } } - // If --virtualhost is set, inject the virtualhost id into the configs - // so the server-side handler receives it as a scalar value for - // single-entry reload. - auto vhost_arg = get_parsed_arguments()->get("virtualhost"); - if (vhost_arg) { - std::string vhost_id = vhost_arg.value(); - if (!vhost_id.empty()) { - configs["virtualhost"] = vhost_id; - } - } using ConfigError = config::reload::errors::ConfigReloadError; diff --git a/src/traffic_ctl/traffic_ctl.cc b/src/traffic_ctl/traffic_ctl.cc index 5296b74a459..1bda6e8521d 100644 --- a/src/traffic_ctl/traffic_ctl.cc +++ b/src/traffic_ctl/traffic_ctl.cc @@ -176,9 +176,7 @@ main([[maybe_unused]] int argc, const char **argv) "Maximum time to wait for reload completion (used with --monitor). " "Accepts duration units: 30s, 1m, 500ms, etc. 0 means no timeout", "", 1, "0") - .with_required("--monitor") - // Include virtualhost option to only reload specified entry - .add_option("--virtualhost", "", "Reload only the specific virtual host entry by id", "", 1, ""); + .with_required("--monitor"); config_command.add_command("status", "Check the configuration status", [&]() { command->execute(); }) .add_option("--token", "-t", "Configuration token to check status.", "", 1, "") From 0f347e79ab61deff84d62654e123ff9f6e6fdc04 Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Tue, 19 May 2026 12:29:17 -0700 Subject: [PATCH 4/9] Update docs and tests --- configs/virtualhost.yaml.default | 2 +- doc/admin-guide/files/records.yaml.en.rst | 2 +- doc/admin-guide/files/virtualhost.yaml.en.rst | 34 +++++++++++++------ src/proxy/VirtualHost.cc | 3 +- .../jsonrpc/config_reload_rpc.test.py | 30 +++++++++------- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/configs/virtualhost.yaml.default b/configs/virtualhost.yaml.default index 1731cb2b08f..eef18964a3b 100644 --- a/configs/virtualhost.yaml.default +++ b/configs/virtualhost.yaml.default @@ -24,4 +24,4 @@ # from: # url: http://example.com # to: -# url: http://origin.example.com/ \ No newline at end of file +# url: http://origin.example.com/ diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 699e1e2cebd..cdfc1734a0e 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -6116,4 +6116,4 @@ VirtualHost .. ts:cv:: CONFIG proxy.config.virtualhost.filename STRING virtualhost.yaml - Sets the name of the :file:`virtualhost.yaml` file. \ No newline at end of file + 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 index b9dab213963..8aa917e8404 100644 --- a/doc/admin-guide/files/virtualhost.yaml.en.rst +++ b/doc/admin-guide/files/virtualhost.yaml.en.rst @@ -116,11 +116,11 @@ selectively reload a single virtual host entry without affecting other virtual h 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: +To reload for a specific virtual host, use new reload directive: :: - $ traffic_ctl config reload --virtualhost + $ 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. @@ -129,10 +129,24 @@ Example: :: - $ traffic_ctl config reload --virtualhost foo - ┌ Virtualhost: foo - └┬ Reload status: ok - ├ Message: Virtualhost successfully reloaded + $ 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 Examples @@ -157,9 +171,9 @@ Examples remap: - type: map from: - url: http://example.com + url: http://www.x.com to: - url: http://origin.example.com/ + url: http://other.example.com/ This rules translates in the following translation. @@ -187,7 +201,7 @@ Client Request Translated Request - id: foo - domains: + domains: - foo.example.com remap: @@ -211,4 +225,4 @@ Client Request Translated Request See Also ======== -:file:`remap.yaml` \ No newline at end of file +:file:`remap.yaml` diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc index b5ece25cd82..552abfa3458 100644 --- a/src/proxy/VirtualHost.cc +++ b/src/proxy/VirtualHost.cc @@ -24,7 +24,6 @@ #include #include #include -#include #include #include "proxy/VirtualHost.h" @@ -253,7 +252,7 @@ VirtualHostConfig::load_entry(std::string_view id, Ptr &entry) return false; } Dbg(dbg_ctl_virtualhost, "Virtualhost with id (%s) not found", id.data()); - return true; + return false; } bool diff --git a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py index 55d38103452..31a9e1eaca1 100644 --- a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py +++ b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py @@ -424,30 +424,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): +def validate_directive_routed(resp: Response): '''virtualhost is not registered — rejected with 6010''' 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 # ============================================================================ From 263ad6b379fbf9b46efdba642bb2f707a9907768 Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Tue, 2 Jun 2026 15:18:06 -0700 Subject: [PATCH 5/9] copilot reviews on docs, wildcard matching, empty vhost entry load --- doc/admin-guide/files/virtualhost.yaml.en.rst | 22 +++++------ include/proxy/VirtualHost.h | 2 + src/proxy/VirtualHost.cc | 38 +++++++++++-------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/doc/admin-guide/files/virtualhost.yaml.en.rst b/doc/admin-guide/files/virtualhost.yaml.en.rst index 8aa917e8404..4cb975d4804 100644 --- a/doc/admin-guide/files/virtualhost.yaml.en.rst +++ b/doc/admin-guide/files/virtualhost.yaml.en.rst @@ -69,10 +69,10 @@ Field Name Description ``domains`` Domains can be defined as request domain name or subdomains using wildcard feature. - Wildcard support only allows single left most ``*``. This does not support regex. + 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 virtual host - config defined first has precedence. + over wildcard. If a domain matches to multiple wildcard domains, the most specific + (longest) suffix match is selected. For example: Supported: @@ -103,8 +103,8 @@ Evaluation Order 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. If multiple entries could match, ATS uses the first matching -entry defined in :file:`virtualhost.yaml`. +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 @@ -170,10 +170,10 @@ Examples # remap.yaml remap: - type: map - from: - url: http://www.x.com - to: - url: http://other.example.com/ + from: + url: http://www.x.com + to: + url: http://other.example.com/ This rules translates in the following translation. @@ -200,8 +200,8 @@ Client Request Translated Request url: http://origin$1.example.com/ - - id: foo - domains: + - id: foo + domains: - foo.example.com remap: diff --git a/include/proxy/VirtualHost.h b/include/proxy/VirtualHost.h index dd84c75a707..07cecfeea25 100644 --- a/include/proxy/VirtualHost.h +++ b/include/proxy/VirtualHost.h @@ -20,6 +20,8 @@ #include #include +#include +#include #include "iocore/eventsystem/ConfigProcessor.h" #include "proxy/http/remap/UrlRewrite.h" diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc index 552abfa3458..08dc2b95242 100644 --- a/src/proxy/VirtualHost.cc +++ b/src/proxy/VirtualHost.cc @@ -21,9 +21,14 @@ limitations under the License. */ +#include +#include +#include #include -#include #include +#include +#include +#include #include #include "proxy/VirtualHost.h" @@ -102,14 +107,13 @@ template <> struct YAML::convert { // Check if domain is wildcard, prefixed with * if (domain[0] == '*') { - const char *subdomain = index(domain, '*'); - if (subdomain && subdomain[1] == '.') { - item.wildcard_domains.push_back(subdomain + 2); - } else { - Dbg(dbg_ctl_virtualhost, "Virtual host wildcard entry must have '*.[domain]' format"); + if (domain[1] != '.' || domain[2] == '\0' || domain[2] == '.' || strchr(domain + 2, '*') != nullptr) { + Dbg(dbg_ctl_virtualhost, "Virtual host wildcard '%s' must match '*.[domain]' format", domain); + return false; } + item.wildcard_domains.emplace_back(domain + 2); } else { - item.exact_domains.push_back(domain); + item.exact_domains.emplace_back(domain); } } @@ -224,7 +228,7 @@ VirtualHostConfig::load_entry(std::string_view id, Ptr &entry) YAML::Node config = YAML::LoadFile(config_path); if (config.IsNull()) { Dbg(dbg_ctl_virtualhost, "Empty virtualhost config: %s", config_path.c_str()); - return true; + return false; } config = config["virtualhost"]; @@ -358,15 +362,17 @@ VirtualHost::startup() ctx.in_progress(); // Single-entry reload requested via -D virtualhost.id= - if (auto directives = ctx.reload_directives(); - directives && directives["id"]) { - std::string id = directives["id"].as(); - if (VirtualHost::reconfigure(id)) { - ctx.complete("Reloaded virtualhost entry: " + id); - } else { - ctx.fail("Failed to reload virtualhost entry: " + id); + if (auto directives = ctx.reload_directives(); directives) { + const auto id_dir = directives["id"]; + if (id_dir && id_dir.IsScalar()) { + std::string id = id_dir.as(); + if (VirtualHost::reconfigure(id)) { + ctx.complete("Reloaded virtualhost entry: " + id); + } else { + ctx.fail("Failed to reload virtualhost entry: " + id); + } + return; } - return; } // Full reload (file-based or no supplied content) From af392557cb5a372a25274b91ea11fa885e83f1db Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Wed, 19 Aug 2026 10:24:55 -0700 Subject: [PATCH 6/9] use shared_ptr, don't set global ip_allow flag --- include/proxy/ReverseProxy.h | 5 + include/proxy/VirtualHost.h | 11 +- include/proxy/http/HttpSM.h | 4 +- src/proxy/ReverseProxy.cc | 10 +- src/proxy/VirtualHost.cc | 145 +++++++++++++----------- src/proxy/http/HttpSM.cc | 37 ++---- src/proxy/http/remap/RemapYamlConfig.cc | 6 +- 7 files changed, 114 insertions(+), 104 deletions(-) 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 index 07cecfeea25..bfbf227e08c 100644 --- a/include/proxy/VirtualHost.h +++ b/include/proxy/VirtualHost.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -50,13 +51,11 @@ class VirtualHostConfig : public ConfigInfo ~VirtualHostConfig() = default; struct Entry : public RefCountObjInHeap { - std::string id; - std::vector exact_domains; - std::vector wildcard_domains; - Ptr remap_table; + std::string id; + std::vector exact_domains; + std::vector wildcard_domains; + std::shared_ptr remap_table; - Entry *acquire() const; - void release() const; std::string get_id() const; }; diff --git a/include/proxy/http/HttpSM.h b/include/proxy/http/HttpSM.h index eb4b6ef18c6..24a535323c4 100644 --- a/include/proxy/http/HttpSM.h +++ b/include/proxy/http/HttpSM.h @@ -313,8 +313,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; - VirtualHostConfig::Entry *m_virtualhost_entry = nullptr; + std::shared_ptr m_remap; + Ptr m_virtualhost_entry; History history; NetVConnection * diff --git a/src/proxy/ReverseProxy.cc b/src/proxy/ReverseProxy.cc index b46088f2108..c3657bb7a0f 100644 --- a/src/proxy/ReverseProxy.cc +++ b/src/proxy/ReverseProxy.cc @@ -82,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() { @@ -145,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")); @@ -211,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 index 08dc2b95242..9e24f9cecaa 100644 --- a/src/proxy/VirtualHost.cc +++ b/src/proxy/VirtualHost.cc @@ -32,6 +32,7 @@ #include #include "proxy/VirtualHost.h" +#include "proxy/ReverseProxy.h" #include "mgmt/config/ConfigRegistry.h" #include "records/RecCore.h" #include "tscore/Filenames.h" @@ -44,25 +45,6 @@ DbgCtl dbg_ctl_virtualhost("virtualhost"); int VirtualHost::_configid = 0; -VirtualHostConfig::Entry * -VirtualHostConfig::Entry::acquire() const -{ - auto *self = const_cast(this); - if (self) { - self->refcount_inc(); - } - return self; -} - -void -VirtualHostConfig::Entry::release() const -{ - auto *self = const_cast(this); - if (self && self->refcount_dec() == 0) { - self->free(); - } -} - std::string VirtualHostConfig::Entry::get_id() const { @@ -83,14 +65,15 @@ template <> struct YAML::convert { } if (!node["id"]) { - Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide `id`"); + Error("Virtualhost entry at line %d must provide `id`", node.Mark().line + 1); return false; } item.id = node["id"].as(); auto domains = node["domains"]; if (!domains || !domains.IsSequence() || domains.size() == 0) { - Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide at least one domain in `domains` sequence"); + 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(); @@ -99,7 +82,7 @@ template <> struct YAML::convert { for (const auto &it : domains) { auto domain_entry = it.as(); if (domain_entry.empty()) { - Dbg(dbg_ctl_virtualhost, "Virtual host entry can't have empty domain entry"); + 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]; @@ -108,7 +91,8 @@ template <> struct YAML::convert { // Check if domain is wildcard, prefixed with * if (domain[0] == '*') { if (domain[1] != '.' || domain[2] == '\0' || domain[2] == '.' || strchr(domain + 2, '*') != nullptr) { - Dbg(dbg_ctl_virtualhost, "Virtual host wildcard '%s' must match '*.[domain]' format", domain); + Error("Virtualhost '%s' wildcard '%s' must match '*.[domain]' format (line %d)", item.id.c_str(), domain, + it.Mark().line + 1); return false; } item.wildcard_domains.emplace_back(domain + 2); @@ -118,7 +102,7 @@ template <> struct YAML::convert { } if (item.exact_domains.empty() && item.wildcard_domains.empty()) { - Dbg(dbg_ctl_virtualhost, "Virtual host entry must have at least one domain defined"); + Error("Virtualhost '%s' must have at least one domain defined (line %d)", item.id.c_str(), node.Mark().line + 1); return false; } @@ -137,7 +121,7 @@ build_virtualhost_entry(YAML::Node const &node, Ptr &e return false; } } catch (YAML::Exception const &ex) { - Dbg(dbg_ctl_virtualhost, "Failed to parse virtualhost entry"); + Error("Failed to parse virtualhost entry at line %d: %s", node.Mark().line + 1, ex.what()); return false; } @@ -146,10 +130,10 @@ build_virtualhost_entry(YAML::Node const &node, Ptr &e if (remap_node) { auto table = std::make_unique(); if (!table->load_table(conf.id, &remap_node)) { - Dbg(dbg_ctl_virtualhost, "Failed to load remap rules for virtualhost entry"); + 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_ptr(table.release()); + conf.remap_table = make_managed_url_rewrite(std::move(table)); } entry = std::move(vhost); return true; @@ -176,7 +160,7 @@ VirtualHostConfig::load() config = config["virtualhost"]; if (config.IsNull() || !config.IsSequence()) { - Dbg(dbg_ctl_virtualhost, "Expected toplevel 'virtualhost' key to be a sequence"); + Error("%s: expected toplevel 'virtualhost' key to be a sequence", config_path.c_str()); return false; } @@ -188,13 +172,14 @@ VirtualHostConfig::load() std::string vhost_id{entry->id}; if (_entries.contains(vhost_id)) { - Dbg(dbg_ctl_virtualhost, "Duplicate virtualhost id: %s", vhost_id.c_str()); + 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)) { - Dbg(dbg_ctl_virtualhost, "Exact domain (%s) already in another virtualhost config", domain.c_str()); + 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); @@ -202,7 +187,8 @@ VirtualHostConfig::load() for (auto const &domain_suffix : entry->wildcard_domains) { if (_wildcard_domains_to_id.contains(domain_suffix)) { - Dbg(dbg_ctl_virtualhost, "Wildcard domain (%s) already in another virtualhost config", domain_suffix.c_str()); + 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); @@ -212,7 +198,7 @@ VirtualHostConfig::load() } } catch (std::exception &ex) { - Dbg(dbg_ctl_virtualhost, "Failed to load %s: %s", config_path.c_str(), ex.what()); + Error("Failed to load %s: %s", config_path.c_str(), ex.what()); return false; } return true; @@ -233,7 +219,7 @@ VirtualHostConfig::load_entry(std::string_view id, Ptr &entry) config = config["virtualhost"]; if (config.IsNull() || !config.IsSequence()) { - Dbg(dbg_ctl_virtualhost, "Expected toplevel 'virtualhost' key to be a sequence"); + Error("%s: expected toplevel 'virtualhost' key to be a sequence", config_path.c_str()); return false; } @@ -252,10 +238,11 @@ VirtualHostConfig::load_entry(std::string_view id, Ptr &entry) } } catch (std::exception &ex) { - Dbg(dbg_ctl_virtualhost, "Failed to load virtualhost entry (%s) in %s: %s", id.data(), config_path.c_str(), ex.what()); + Error("Failed to load virtualhost entry '%.*s' in %s: %s", static_cast(id.size()), id.data(), config_path.c_str(), + ex.what()); return false; } - Dbg(dbg_ctl_virtualhost, "Virtualhost with id (%s) not found", id.data()); + Error("%s: virtualhost with id '%.*s' not found", config_path.c_str(), static_cast(id.size()), id.data()); return false; } @@ -279,7 +266,8 @@ VirtualHostConfig::set_entry(std::string_view id, Ptr &entry) if (entry) { for (auto const &domain : entry->exact_domains) { if (_exact_domains_to_id.contains(domain)) { - Dbg(dbg_ctl_virtualhost, "Exact domain (%s) already in another virtualhost config", domain.c_str()); + 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); @@ -287,7 +275,8 @@ VirtualHostConfig::set_entry(std::string_view id, Ptr &entry) for (auto const &domain_suffix : entry->wildcard_domains) { if (_wildcard_domains_to_id.contains(domain_suffix)) { - Dbg(dbg_ctl_virtualhost, "Wildcard domain (%s) already in another virtualhost config", domain_suffix.c_str()); + 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); @@ -347,6 +336,52 @@ VirtualHostConfig::find_by_domain(std::string_view domain) const 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) { + 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 (VirtualHost::reconfigure(id)) { + ctx.complete("Reloaded virtualhost entry: " + id); + } else { + ctx.fail("Failed to reload virtualhost entry: " + id); + } + return; + } + } + + if (VirtualHost::reconfigure()) { + ctx.complete("Finished loading virtualhost config"); + } else { + ctx.fail("Failed to load virtualhost config"); + } +} +} // namespace + void VirtualHost::startup() { @@ -355,35 +390,13 @@ VirtualHost::startup() } RecRegisterConfigUpdateCb("proxy.config.virtualhost.filename", &VirtualHost::config_callback, nullptr); - config::ConfigRegistry::Get_Instance().register_config("virtualhost", // registry key - ts::filename::VIRTUALHOST, // default filename - "proxy.config.virtualhost.filename", // record holding the filename - [](ConfigContext ctx) { - ctx.in_progress(); - - // Single-entry reload requested via -D virtualhost.id= - if (auto directives = ctx.reload_directives(); directives) { - const auto id_dir = directives["id"]; - if (id_dir && id_dir.IsScalar()) { - std::string id = id_dir.as(); - if (VirtualHost::reconfigure(id)) { - ctx.complete("Reloaded virtualhost entry: " + id); - } else { - ctx.fail("Failed to reload virtualhost entry: " + id); - } - return; - } - } - - // Full reload (file-based or no supplied content) - if (VirtualHost::reconfigure()) { - ctx.complete("Finished loading virtualhost config"); - } else { - ctx.fail("Failed to load virtualhost config"); - } - }, - config::ConfigSource::FileAndRpc, // supports RPC content - {"proxy.config.virtualhost.filename"}); // trigger records + 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 diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index f74a9228d62..fb173e5f7ef 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -281,11 +281,6 @@ HttpSM::HttpSM() : Continuation(nullptr), vc_table(this) {} HttpSM::~HttpSM() { - if (m_virtualhost_entry) { - m_virtualhost_entry->release(); - m_virtualhost_entry = nullptr; - } - http_parser_clear(&http_parser); // coverity[exn_spec_violation] - release() only does ref counting and delete on POD types @@ -4698,8 +4693,7 @@ HttpSM::set_virtualhost_entry(std::string_view domain) 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()); - // Explicitly acquire() since HttpSM holds raw pointer - m_virtualhost_entry = vhost_entry->acquire(); + m_virtualhost_entry = std::move(vhost_entry); } } @@ -4714,23 +4708,14 @@ HttpSM::do_remap_request(bool run_inline) set_virtualhost_entry(host_name); } - // Check virtualhost remap rules before looking at remap.config + // 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) { - UrlRewrite *vhost_table = m_virtualhost_entry->remap_table->acquire(); - if (vhost_table) { - // If already acquired, release ref - if (vhost_table == m_remap) { - vhost_table->release(); - } else { - if (m_remap) { - m_remap->release(); - } - m_remap = vhost_table; - } - SMDbg(dbg_ctl_url_rewrite, "Using virtualhost remap table: %s", m_virtualhost_entry->get_id().c_str()); - virtualhost_remap = true; - } + 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()); @@ -4738,11 +4723,9 @@ HttpSM::do_remap_request(bool run_inline) // 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"); - if (m_remap) { - m_remap->release(); - } - m_remap = rewrite_table.load()->acquire(); - ret = remapProcessor.setup_for_remap(&t_state, m_remap); + // 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(); diff --git a/src/proxy/http/remap/RemapYamlConfig.cc b/src/proxy/http/remap/RemapYamlConfig.cc index 0a313ee1786..11c2eefbcc2 100644 --- a/src/proxy/http/remap/RemapYamlConfig.cc +++ b/src/proxy/http/remap/RemapYamlConfig.cc @@ -1054,7 +1054,11 @@ remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, Config } } - IpAllow::enableAcceptCheck(bti->accept_check_p); + // 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; From 7b8371a5c3b2c7bd50c99bf5f58badd1e36a65ed Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Mon, 24 Aug 2026 16:22:30 -0700 Subject: [PATCH 7/9] clear all, explicitly set remap_yaml --- src/proxy/VirtualHost.cc | 3 +++ src/proxy/http/remap/UrlRewrite.cc | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc index 9e24f9cecaa..9e05f2223cb 100644 --- a/src/proxy/VirtualHost.cc +++ b/src/proxy/VirtualHost.cc @@ -129,6 +129,7 @@ build_virtualhost_entry(YAML::Node const &node, Ptr &e auto remap_node = node["remap"]; if (remap_node) { auto table = std::make_unique(); + table->set_remap_yaml(true); if (!table->load_table(conf.id, &remap_node)) { Error("Failed to load remap rules for virtualhost '%s' at line %d", conf.id.c_str(), remap_node.Mark().line + 1); return false; @@ -143,6 +144,8 @@ bool VirtualHostConfig::load() { _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; diff --git a/src/proxy/http/remap/UrlRewrite.cc b/src/proxy/http/remap/UrlRewrite.cc index a203041e90c..9e3022c2ca8 100644 --- a/src/proxy/http/remap/UrlRewrite.cc +++ b/src/proxy/http/remap/UrlRewrite.cc @@ -101,10 +101,6 @@ UrlRewrite::load(ConfigContext ctx) bool UrlRewrite::load_table(const std::string &config_file_path, YAML::Node const *remap_node, ConfigContext ctx) { - if (remap_node) { - this->_remap_yaml = true; - } - this->ts_name = nullptr; if (auto rec_str{RecGetRecordStringAlloc("proxy.config.proxy_name")}; rec_str) { this->ts_name = ats_stringdup(rec_str); From 267f1ad045170155adb2259362abf390ee0568ed Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Mon, 24 Aug 2026 17:44:01 -0700 Subject: [PATCH 8/9] Add more config_reload_rpc tests --- src/proxy/VirtualHost.cc | 6 + .../autest-site/trafficserver.test.ext | 4 + .../jsonrpc/config_reload_rpc.test.py | 126 +++++++++++++++++- 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc index 9e05f2223cb..d51dabda248 100644 --- a/src/proxy/VirtualHost.cc +++ b/src/proxy/VirtualHost.cc @@ -213,6 +213,12 @@ VirtualHostConfig::load_entry(std::string_view id, Ptr &entry) 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) { + Warning("Virtualhost configuration '%s' doesn't exist", config_path.c_str()); + return false; + } + try { YAML::Node config = YAML::LoadFile(config_path); if (config.IsNull()) { 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 31a9e1eaca1..3b251d94577 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) # ============================================================================ @@ -435,7 +443,7 @@ def validate_directive_fileonly(resp: Response): def validate_directive_routed(resp: Response): - '''virtualhost is not registered — rejected with 6010''' + '''virtualhost is registered as FileAndRpc — the directive should be accepted and scheduled''' result = resp.result errors = result.get('errors', []) @@ -495,3 +503,119 @@ 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. +# ============================================================================ +tr = Test.AddTestRun("virtualhost rejects pushed config content") +tr.DelayStart = 2 +tr.AddJsonRPCClientRequest( + ts, Request.admin_config_reload(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 + +ts.Disk.diags_log.Content += Testers.ContainsExpression( + "virtualhost does not accept config content over rpc", "Pushed virtualhost content should be rejected") + +# ============================================================================ +# 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. +# ============================================================================ +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") + +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(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 + +# ============================================================================ +# 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. +# ============================================================================ +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", "Missing virtualhost.yaml should be reported as a warning") +ts_missing.Disk.diags_log.Content += Testers.ExcludesExpression( + "bad file", "Missing virtualhost.yaml should not surface a yaml-cpp load failure") + +tr = Test.AddTestRun("Single-entry reload with no virtualhost.yaml") +tr.Processes.Default.StartBefore(ts_missing) +tr.AddJsonRPCClientRequest( + ts_missing, Request.admin_config_reload(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 From a162e274ed718c0b0981f2953a99673134a763d7 Mon Sep 17 00:00:00 2001 From: Serris Santos Date: Tue, 25 Aug 2026 09:43:48 -0700 Subject: [PATCH 9/9] use rpc response instead of diags --- .../jsonrpc/config_reload_rpc.test.py | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py index 3b251d94577..62672e17660 100644 --- a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py +++ b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py @@ -507,15 +507,21 @@ def validate_directive_mixed(resp: Response): # ============================================================================ # 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. +# 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(configs={"virtualhost": [{ - "id": "pushed.example.com", - "domains": ["pushed.example.com"] - }]})) + 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): @@ -532,8 +538,47 @@ def validate_content_rejected(resp: Response): tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_content_rejected) tr.StillRunningAfter = ts -ts.Disk.diags_log.Content += Testers.ContainsExpression( - "virtualhost does not accept config content over rpc", "Pushed virtualhost content should be rejected") +# 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