Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions doc/admin-guide/files/records.yaml.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4177,6 +4177,52 @@ SSL Termination
:file:`ssl_multicert.yaml` file successfully load. If false (``0``), SSL certificate
load failures will not prevent |TS| from starting.

.. ts:cv:: CONFIG proxy.config.ssl.server.multicert.partial_reload INT 0
:reloadable:

When set to ``1``, a live ``traffic_ctl config reload`` that encounters one or more
certificate load failures will still commit the partial ``SSLCertLookup``
(containing all certificates that did load cleanly) instead of discarding the entire
new configuration and keeping the old one.

By default (``0``), the reload is strict and all-or-nothing: any failure causes the
entire new configuration to be rejected and the previous configuration to remain active.

When the knob is on, any skipped certificate still produces an ``ERROR`` entry in
:file:`diags.log` and increments the
``proxy.process.ssl.ssl_multicert_load_failures`` metric, so degraded state
is never silent. The metric is also incremented in strict mode (``0``) and can
be used as an alert signal in both configurations. Note that the counter only
covers live reloads via :program:`traffic_ctl`; failures during the initial
startup load are not counted because the statistics subsystem is not yet
initialized at that point.

This knob takes effect on the next ``traffic_ctl config reload`` without requiring
a process restart.

.. note::

This knob is independent of :ts:cv:`proxy.config.ssl.server.multicert.exit_on_load_fail`.
That option governs startup behavior only. ``partial_reload`` governs live reloads
via :program:`traffic_ctl`.

.. note::

When a partial reload is committed, any hostname whose certificate failed to load
will immediately fall back to the bare TLS bootstrap context (no certificate) rather
than continuing to serve its previously-loaded certificate. Strict mode (``0``) is
safer in this regard, all hostnames continue serving their old certificates until
a fully-successful reload, but at the cost of blocking all certificate updates
whenever any single certificate fails. Enabling ``partial_reload`` trades per-hostname
reliability for reduced blast radius across the certificate set.

.. note::

This knob applies only to TLS server certificate loading (``SSLCertificateConfig``).
The QUIC/HTTP3 certificate loader (``QUICCertConfig``) is not yet covered and continues
to use strict all-or-nothing semantics regardless of this setting. Deployments without
a ``dest_ip: "*"`` wildcard entry (SNI-only configurations) are fully supported.

.. ts:cv:: CONFIG proxy.config.ssl.server.multicert.concurrency INT 1

Controls how many threads are used to load SSL certificates from :file:`ssl_multicert.yaml`
Expand Down
16 changes: 16 additions & 0 deletions doc/admin-guide/monitoring/statistics/core/ssl.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ SSL/TLS
.. ts:stat:: global proxy.process.ssl.ssl_sni_name_set_failure integer
:type: counter

.. ts:stat:: global proxy.process.ssl.ssl_multicert_load_failures integer
:type: counter

Counts certificates that failed to load during live SSL configuration reloads
triggered by :program:`traffic_ctl`.
When :ts:cv:`proxy.config.ssl.server.multicert.partial_reload` is enabled, failed
certificates are skipped and the remaining valid certificates are committed. This
counter increments regardless of that setting and can be used to alert on cert
load failures even in strict (default) mode.

.. note::

This counter does not include failures from the initial startup load.
The statistics subsystem is not yet initialized when startup cert loading
runs, so those failures are only visible in :file:`diags.log`.

.. ts:stat:: global proxy.process.ssl.total_handshake_time integer
:type: counter
:units: milliseconds
Expand Down
1 change: 1 addition & 0 deletions src/iocore/net/P_SSLConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ struct SSLConfigParams : public ConfigInfo {
char *cipherSuite;
char *client_cipherSuite;
int configExitOnLoadError;
int configPartialReload; ///< When 1, commit a partial SSLCertLookup on reload even if some certs failed.
int configLoadConcurrency;
int clientCertLevel;
int verify_depth;
Expand Down
17 changes: 14 additions & 3 deletions src/iocore/net/SSLConfig.cc
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ SSLConfigParams::reset()
ssl_ctx_options = SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3;
ssl_client_ctx_options = SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3;
configExitOnLoadError = 1;
configPartialReload = 0;
configLoadConcurrency = 1;
}

Expand Down Expand Up @@ -439,6 +440,7 @@ SSLConfigParams::initialize(ConfigContext ctx)

configFilePath = ats_stringdup(RecConfigReadConfigPath("proxy.config.ssl.server.multicert.filename"));
configExitOnLoadError = RecGetRecordInt("proxy.config.ssl.server.multicert.exit_on_load_fail").value_or(0);
configPartialReload = RecGetRecordInt("proxy.config.ssl.server.multicert.partial_reload").value_or(0);
configLoadConcurrency = RecGetRecordInt("proxy.config.ssl.server.multicert.concurrency").value_or(1);
if (configLoadConcurrency == 0) {
configLoadConcurrency = std::clamp(static_cast<int>(std::thread::hardware_concurrency()), 1, 256);
Expand Down Expand Up @@ -701,10 +703,19 @@ SSLCertificateConfig::reconfigure(ConfigContext ctx)
retStatus = false;
}

// If the load succeeded, load it. If there is no current configuration, load even a broken
// config so that a bad initial load doesn't completely disable TLS.
if (retStatus || configid == 0) {
// Commit if load succeeded, this is the initial load, or partial_reload is on and at least one
// real cert was inserted. count(RSA|EC) is used instead of ssl_default because ssl_default is always
// set to a bare bootstrap context. Both storages are checked because BoringSSL routes EC certs
// into ec_storage, so count(RSA) alone returns 0 for an EC-only config.
const bool hasAnyCert = lookup->count(SSLCertContextType::RSA) > 0 || lookup->count(SSLCertContextType::EC) > 0;
const bool partialCommit = !retStatus && params->configPartialReload && hasAnyCert;
const bool initialLoad = (configid == 0);
if (retStatus || initialLoad || partialCommit) {
configid = configProcessor.set(configid, lookup);
// Only flip retStatus on live reloads, startup must preserve false to honour configExitOnLoadError.
if (partialCommit && !initialLoad) {
retStatus = true;
}
} else {
delete lookup;
}
Expand Down
1 change: 1 addition & 0 deletions src/iocore/net/SSLStats.cc
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ SSLInitializeStatistics()
ssl_rsb.user_agent_wrong_version = Metrics::Counter::createPtr("proxy.process.ssl.user_agent_wrong_version");
ssl_rsb.tls_handshake_bytes_in_total = Metrics::Counter::createPtr("proxy.process.ssl.total_handshake_bytes_read_in");
ssl_rsb.tls_handshake_bytes_out_total = Metrics::Counter::createPtr("proxy.process.ssl.total_handshake_bytes_write_out");
ssl_rsb.ssl_multicert_load_failures = Metrics::Counter::createPtr("proxy.process.ssl.ssl_multicert_load_failures");

#if defined(OPENSSL_IS_BORINGSSL)
size_t n = SSL_get_all_cipher_names(nullptr, 0);
Expand Down
3 changes: 3 additions & 0 deletions src/iocore/net/SSLStats.h
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ struct SSLStatsBlock {
Metrics::Gauge::AtomicType *user_agent_session_miss = nullptr;
Metrics::Gauge::AtomicType *user_agent_session_timeout = nullptr;
Metrics::Gauge::AtomicType *user_agent_sessions = nullptr;

Metrics::Counter::AtomicType *ssl_multicert_load_failures =
nullptr; ///< Incremented per cert that fails to load during ssl_multicert reload.
};

extern SSLStatsBlock ssl_rsb;
Expand Down
7 changes: 7 additions & 0 deletions src/iocore/net/SSLUtils.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,13 @@ SSLMultiCertConfigLoader::_load_items(SSLCertLookup *lookup, config::SSLMultiCer
std::lock_guard<std::mutex> lock(_loader_mutex);
errata.note(ERRATA_ERROR, "Failed to load certificate '{}' at item {}",
sslMultiCertSettings->cert ? sslMultiCertSettings->cert : "(unnamed)", item_num);
// Guard required: SSLCertificateConfig::startup() (which calls _load_items()) always
// runs before SSLInitializeStatistics() in the normal startup path (see
// SSLNetProcessor::start()), so this counter is nullptr during the initial cert
// load. Other counters are only incremented from paths that execute after stats init.
if (ssl_rsb.ssl_multicert_load_failures) {
Metrics::Counter::increment(ssl_rsb.ssl_multicert_load_failures);
}
}
} else {
std::lock_guard<std::mutex> lock(_loader_mutex);
Expand Down
2 changes: 2 additions & 0 deletions src/records/RecordsConfig.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,8 @@ static constexpr RecordElement RecordsConfig[] =
,
{RECT_CONFIG, "proxy.config.ssl.server.multicert.exit_on_load_fail", RECD_INT, "1", RECU_RESTART_TS, RR_NULL, RECC_INT, "[0-1]", RECA_NULL}
,
{RECT_CONFIG, "proxy.config.ssl.server.multicert.partial_reload", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-1]", RECA_NULL}
,
{RECT_CONFIG, "proxy.config.ssl.server.multicert.concurrency", RECD_INT, "1", RECU_RESTART_TS, RR_NULL, RECC_INT, "[0-256]", RECA_NULL}
,
{RECT_CONFIG, "proxy.config.ssl.servername.filename", RECD_STRING, ts::filename::SNI, RECU_RESTART_TS, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
Expand Down
Loading