blob: f02ae6348fbe1058f3e3d3018a3bcabb06cb4c5d [file]
/** @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);
}
}