Copilot commented on code in PR #13108: URL: https://github.com/apache/trafficserver/pull/13108#discussion_r3337482724
########## 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 <string> +#include <string_view> + +#include "iocore/eventsystem/ConfigProcessor.h" +#include "proxy/http/remap/UrlRewrite.h" +#include "tscore/Ptr.h" Review Comment: VirtualHost.h uses std::vector and std::unordered_map but doesn't include <vector> / <unordered_map>. This makes the header non-self-contained and can break compilation depending on include order. ########## src/proxy/VirtualHost.cc: ########## @@ -0,0 +1,449 @@ +/** @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 <set> +#include <string_view> +#include <string> +#include <yaml-cpp/yaml.h> Review Comment: This file uses struct stat/stat(), errno/ENOENT, std::none_of, std::make_unique, and index() but doesn't include the headers that declare them. Relying on transitive includes is fragile and can fail to compile on some platforms/toolchains. ########## src/proxy/VirtualHost.cc: ########## @@ -0,0 +1,449 @@ +/** @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 <set> +#include <string_view> +#include <string> +#include <yaml-cpp/yaml.h> + +#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<Entry *>(this); + if (self) { + self->refcount_inc(); + } + return self; +} + +void +VirtualHostConfig::Entry::release() const +{ + auto *self = const_cast<Entry *>(this); + if (self && self->refcount_dec() == 0) { + self->free(); + } +} + +std::string +VirtualHostConfig::Entry::get_id() const +{ + return id; +} + +std::set<std::string> valid_vhost_keys = {"id", "domains", "remap"}; + +template <> struct YAML::convert<VirtualHostConfig::Entry> { + 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<std::string>(); })) { + Warning("unsupported key '%s' in VirtualHost config", elem.first.as<std::string>().c_str()); + } + } + + if (!node["id"]) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide `id`"); + return false; + } + item.id = node["id"].as<std::string>(); + + 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<std::string>(); + 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<VirtualHostConfig::Entry> &entry) +{ + entry.clear(); + Ptr<VirtualHostConfig::Entry> vhost = make_ptr(new VirtualHostConfig::Entry); + auto &conf = *vhost; + try { + if (!YAML::convert<VirtualHostConfig::Entry>::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<UrlRewrite>(); + 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> 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) +{ + 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; + } Review Comment: load_entry() treats an empty virtualhost config as success (returns true) while leaving 'entry' unset. In VirtualHost::reconfigure(id), this ends up calling set_entry(id, nullptr), which will delete any existing entry for that id. Empty config should fail the single-entry reload rather than silently removing entries. ########## src/proxy/VirtualHost.cc: ########## @@ -0,0 +1,449 @@ +/** @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 <set> +#include <string_view> +#include <string> +#include <yaml-cpp/yaml.h> + +#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<Entry *>(this); + if (self) { + self->refcount_inc(); + } + return self; +} + +void +VirtualHostConfig::Entry::release() const +{ + auto *self = const_cast<Entry *>(this); + if (self && self->refcount_dec() == 0) { + self->free(); + } +} + +std::string +VirtualHostConfig::Entry::get_id() const +{ + return id; +} + +std::set<std::string> valid_vhost_keys = {"id", "domains", "remap"}; + +template <> struct YAML::convert<VirtualHostConfig::Entry> { + 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<std::string>(); })) { + Warning("unsupported key '%s' in VirtualHost config", elem.first.as<std::string>().c_str()); + } + } + + if (!node["id"]) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide `id`"); + return false; + } + item.id = node["id"].as<std::string>(); + + 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<std::string>(); + 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); + } + } Review Comment: Wildcard domain validation doesn't enforce the documented "single left-most *" rule. Patterns like "*.*.baz.com" will currently be accepted and stored as a suffix containing '*', which will never match and is likely a config mistake. ########## doc/admin-guide/files/virtualhost.yaml.en.rst: ########## @@ -0,0 +1,228 @@ + +.. 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 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`. Review Comment: This section says ATS uses the first matching virtualhost entry in the file when multiple entries match. The current matching code instead selects exact matches first, then the most-specific wildcard suffix (longest suffix) rather than file order. Please adjust this description to match the actual selection logic (or update the implementation). ########## doc/admin-guide/files/virtualhost.yaml.en.rst: ########## @@ -0,0 +1,228 @@ + +.. 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 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 new reload directive: + +:: + + $ traffic_ctl config reload -D virtualhost.id=<id> + +Where **<id>** is the virtual host ID defined in :file:`virtualhost.yaml`. Only the **<id>** 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 + + +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/ + +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/ Review Comment: This virtualhost.yaml example has invalid indentation for the second list item and a malformed URL ("http:/foo..."). As written, it won't parse and could mislead users. ########## 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', []) Review Comment: The docstring still says virtualhost is unregistered / rejected with 6010, but this test now expects the directive to be routed to the registered handler. Update the docstring to match the behavior being validated. ########## doc/admin-guide/files/virtualhost.yaml.en.rst: ########## @@ -0,0 +1,228 @@ + +.. 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 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. + Review Comment: The stated wildcard precedence doesn't match the implementation. VirtualHostConfig::find_by_domain() checks suffixes from most-specific to least-specific (e.g. "example.com" before "com"), not "first defined in virtualhost.yaml". Please update this text (or change the matching logic) so behavior is unambiguous. ########## src/proxy/http/HttpSM.cc: ########## @@ -4569,13 +4574,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) { Review Comment: This change adds new runtime behavior (virtualhost selection + per-virtualhost remap table override + fallback to global remap rules). There are extensive gold tests for remap.yaml behavior, but there doesn't appear to be a gold test covering virtualhost.yaml domain matching (exact vs wildcard), remap precedence, and fallback behavior. Adding a focused gold test would help prevent regressions. ########## src/proxy/VirtualHost.cc: ########## @@ -0,0 +1,449 @@ +/** @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 <set> +#include <string_view> +#include <string> +#include <yaml-cpp/yaml.h> + +#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<Entry *>(this); + if (self) { + self->refcount_inc(); + } + return self; +} + +void +VirtualHostConfig::Entry::release() const +{ + auto *self = const_cast<Entry *>(this); + if (self && self->refcount_dec() == 0) { + self->free(); + } +} + +std::string +VirtualHostConfig::Entry::get_id() const +{ + return id; +} + +std::set<std::string> valid_vhost_keys = {"id", "domains", "remap"}; + +template <> struct YAML::convert<VirtualHostConfig::Entry> { + 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<std::string>(); })) { + Warning("unsupported key '%s' in VirtualHost config", elem.first.as<std::string>().c_str()); + } + } + + if (!node["id"]) { + Dbg(dbg_ctl_virtualhost, "Virtual host entry must provide `id`"); + return false; + } + item.id = node["id"].as<std::string>(); + + 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<std::string>(); + 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<VirtualHostConfig::Entry> &entry) +{ + entry.clear(); + Ptr<VirtualHostConfig::Entry> vhost = make_ptr(new VirtualHostConfig::Entry); + auto &conf = *vhost; + try { + if (!YAML::convert<VirtualHostConfig::Entry>::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<UrlRewrite>(); + 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> 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) +{ + 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<std::string>() != id) { + continue; + } + + Ptr<Entry> 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 false; +} + +bool +VirtualHostConfig::set_entry(std::string_view id, Ptr<Entry> &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<Entry> 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::Entry> +VirtualHostConfig::find_by_id(std::string_view id) const +{ + if (_entries.empty()) { + return Ptr<VirtualHostConfig::Entry>(); + } + + auto entry = _entries.find(std::string{id}); + if (entry != _entries.end()) { + return entry->second; + } + return Ptr<VirtualHostConfig::Entry>(); +} + +Ptr<VirtualHostConfig::Entry> +VirtualHostConfig::find_by_domain(std::string_view domain) const +{ + if (_entries.empty() || domain.empty()) { + return Ptr<VirtualHostConfig::Entry>(); + } + + 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<VirtualHostConfig::Entry>(); +} + +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(); + + // Single-entry reload requested via -D virtualhost.id=<id> + if (auto directives = ctx.reload_directives(); + directives && directives["id"]) { + std::string id = directives["id"].as<std::string>(); + if (VirtualHost::reconfigure(id)) { + ctx.complete("Reloaded virtualhost entry: " + id); + } else { + ctx.fail("Failed to reload virtualhost entry: " + id); + } + return; + } Review Comment: reload_directives() returns a YAML::Node by value. Using non-const operator[] (directives["id"]) will create the key even when it doesn't exist, causing this branch to run and then .as<std::string>() to throw. Make the node const (so operator[] doesn't insert) and validate the id node before converting. ########## doc/admin-guide/files/virtualhost.yaml.en.rst: ########## @@ -0,0 +1,228 @@ + +.. 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 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 new reload directive: + +:: + + $ traffic_ctl config reload -D virtualhost.id=<id> + +Where **<id>** is the virtual host ID defined in :file:`virtualhost.yaml`. Only the **<id>** 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 + + +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/ Review Comment: The remap.yaml example has incorrect YAML indentation ("from" is indented too far), so the snippet is not valid YAML as written. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
