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