This is an automated email from the ASF dual-hosted git repository. markt pushed a commit to branch 9.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/9.0.x by this push: new 7d388dbd3e Fix BZ 65770 - add a listener to reload TLS certificates 7d388dbd3e is described below commit 7d388dbd3e43fd9c1406a464505449c0b770b12f Author: Mark Thomas <ma...@apache.org> AuthorDate: Mon Sep 25 15:46:27 2023 +0100 Fix BZ 65770 - add a listener to reload TLS certificates The listener is intended to be used in environments where a 3rd party component (e.g. certbot) is responsible for regularly renewing the certificate. https://bz.apache.org/bugzilla/show_bug.cgi?id=65770 --- .../catalina/security/LocalStrings.properties | 4 + .../security/TLSCertificateReloadListener.java | 176 +++++++++++++++++++++ java/org/apache/coyote/ProtocolHandler.java | 13 ++ .../org/apache/coyote/ajp/AbstractAjpProtocol.java | 6 + .../coyote/http11/AbstractHttp11Protocol.java | 6 + java/org/apache/tomcat/util/net/SSLHostConfig.java | 26 +++ webapps/docs/changelog.xml | 6 + webapps/docs/config/listeners.xml | 37 +++++ 8 files changed, 274 insertions(+) diff --git a/java/org/apache/catalina/security/LocalStrings.properties b/java/org/apache/catalina/security/LocalStrings.properties index f1e1aa689c..b2f6cdf023 100644 --- a/java/org/apache/catalina/security/LocalStrings.properties +++ b/java/org/apache/catalina/security/LocalStrings.properties @@ -22,3 +22,7 @@ SecurityListener.checkUserWarning=Start attempted while running as user [{0}]. R SecurityUtil.doAsPrivilege=An exception occurs when running the PrivilegedExceptionAction block. listener.notServer=This listener must only be nested within Server elements, but is in [{0}]. + +tlsCertRenewalListener.notRenewed=[{0}], TLS virtual host [{1}] with name [{2}] that expires on [{3}] is overdue for renewal +tlsCertRenewalListener.reloadFailed=[{0}], TLS virtual host [{1}] reload of TLS configuration failed +tlsCertRenewalListener.reloadSuccess=[{0}], TLS virtual host [{1}] reloaded TLS configuration \ No newline at end of file diff --git a/java/org/apache/catalina/security/TLSCertificateReloadListener.java b/java/org/apache/catalina/security/TLSCertificateReloadListener.java new file mode 100644 index 0000000000..d9fe20f462 --- /dev/null +++ b/java/org/apache/catalina/security/TLSCertificateReloadListener.java @@ -0,0 +1,176 @@ +/* + * 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. + */ +package org.apache.catalina.security; + +import java.security.cert.X509Certificate; +import java.util.Calendar; +import java.util.Set; + +import org.apache.catalina.Lifecycle; +import org.apache.catalina.LifecycleEvent; +import org.apache.catalina.LifecycleListener; +import org.apache.catalina.Server; +import org.apache.catalina.Service; +import org.apache.catalina.connector.Connector; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; +import org.apache.tomcat.util.net.SSLHostConfig; +import org.apache.tomcat.util.res.StringManager; + +/** + * A {@link LifecycleListener} that may be used to monitor the expiration dates of TLS certificates and trigger + * automatic reloading of the TLS configuration a set number of days before the TLS certificate expires. + * <p> + * This listener assumes there is some other process (certbot, cloud infrastructure, etc) that renews the certificate on + * a regular basis and replaces the current certificate with the new one. + * <p> + * This listener does <b>NOT</b> re-read the Tomcat configuration from server.xml. If you make changes to server.xml you + * must restart the Tomcat process to pick up those changes. + * <p> + */ +public class TLSCertificateReloadListener implements LifecycleListener { + + private static final Log log = LogFactory.getLog(TLSCertificateReloadListener.class); + private static final StringManager sm = StringManager.getManager(TLSCertificateReloadListener.class); + + // Configuration + private int checkPeriod = 24 * 60 * 60; + private int daysBefore = 14; + + // State + private Calendar nextCheck = Calendar.getInstance(); + + + /** + * Get the time, in seconds, between reloading checks. + * <p> + * The periodic process for {@code LifecycleListener} typically runs much more frequently than this listener + * requires. This attribute controls the period between checks. + * <p> + * If not specified, a default of 86,400 seconds (24 hours) is used. + * + * @return The time, in seconds, between reloading checks + */ + public int getCheckPeriod() { + return checkPeriod; + } + + + /** + * Set the time, in seconds, between reloading checks. + * + * @param checkPeriod The new time, in seconds, between reloading checks + */ + public void setCheckPeriod(int checkPeriod) { + this.checkPeriod = checkPeriod; + } + + + /** + * Get the number of days before the expiry of a TLS certificate that it is expected that the new certificate will + * be in place and the reloading can be triggered. + * <p> + * If not specified, a default of 14 days is used. + * + * @return The number of days before the expiry of a TLS certificate that the reloading will be triggered + */ + public int getDaysBefore() { + return daysBefore; + } + + + /** + * Set the number of days before the expiry of a TLS certificate that it is expected that the new certificate will + * be in place and the reloading can be triggered. + * + * @param daysBefore the number of days before the expiry of the current certificate that reloading will be + * triggered + */ + public void setDaysBefore(int daysBefore) { + this.daysBefore = daysBefore; + } + + + @Override + public void lifecycleEvent(LifecycleEvent event) { + if (event.getType().equals(Lifecycle.PERIODIC_EVENT)) { + Server server; + if (event.getSource() instanceof Server) { + server = (Server) event.getSource(); + } else { + return; + } + checkCertificatesForRenewal(server); + } else if (event.getType().equals(Lifecycle.BEFORE_INIT_EVENT)) { + // This is the earliest event in Lifecycle + if (!(event.getLifecycle() instanceof Server)) { + log.warn(sm.getString("listener.notServer", event.getLifecycle().getClass().getSimpleName())); + } + } + } + + + private void checkCertificatesForRenewal(Server server) { + // Only run the check once every checkPeriod (seconds) + Calendar calendar = Calendar.getInstance(); + if (calendar.compareTo(nextCheck) > 0) { + nextCheck.add(Calendar.SECOND, getCheckPeriod()); + } else { + return; + } + + /* + * Advance current date by "daysBefore". Any certificates that expire before this time should have been renewed + * by now so reloading the associated SSLHostConfig should pick up the new certificate. + */ + calendar.add(Calendar.DAY_OF_MONTH, getDaysBefore()); + + // Check all of the certificates + Service[] services = server.findServices(); + for (Service service : services) { + Connector[] connectors = service.findConnectors(); + for (Connector connector : connectors) { + SSLHostConfig[] sslHostConfigs = connector.findSslHostConfigs(); + for (SSLHostConfig sslHostConfig : sslHostConfigs) { + if (!sslHostConfig.certificatesExpiringBefore(calendar.getTime()).isEmpty()) { + // One or more certificates is due to expire and should have been renewed + // Reload the configuration + try { + connector.getProtocolHandler().addSslHostConfig(sslHostConfig, true); + // Now check again + Set<X509Certificate> expiringCertificates = + sslHostConfig.certificatesExpiringBefore(calendar.getTime()); + log.info(sm.getString("tlsCertRenewalListener.reloadSuccess", connector, + sslHostConfig.getHostName())); + if (!expiringCertificates.isEmpty()) { + for (X509Certificate expiringCertificate : expiringCertificates) { + log.warn(sm.getString("tlsCertRenewalListener.notRenewed", connector, + sslHostConfig.getHostName(), + expiringCertificate.getSubjectX500Principal().getName(), + expiringCertificate.getNotAfter())); + } + } + } catch (IllegalArgumentException iae) { + log.error(sm.getString("tlsCertRenewalListener.reloadFailed", connector, + sslHostConfig.getHostName()), iae); + } + } + } + } + } + } +} diff --git a/java/org/apache/coyote/ProtocolHandler.java b/java/org/apache/coyote/ProtocolHandler.java index 7e7dfdb819..55de6e494f 100644 --- a/java/org/apache/coyote/ProtocolHandler.java +++ b/java/org/apache/coyote/ProtocolHandler.java @@ -173,6 +173,19 @@ public interface ProtocolHandler { void addSslHostConfig(SSLHostConfig sslHostConfig); + /** + * Add a new SSL configuration for a virtual host. + * + * @param sslHostConfig the configuration + * @param replace If {@code true} replacement of an existing configuration is permitted, otherwise any such + * attempted replacement will trigger an exception + * + * @throws IllegalArgumentException If the host name is not valid or if a configuration has already been provided + * for that host and replacement is not allowed + */ + void addSslHostConfig(SSLHostConfig sslHostConfig, boolean replace); + + /** * Find all configured SSL virtual host configurations which will be used by SNI. * diff --git a/java/org/apache/coyote/ajp/AbstractAjpProtocol.java b/java/org/apache/coyote/ajp/AbstractAjpProtocol.java index ff64a1cd0b..8241a7b964 100644 --- a/java/org/apache/coyote/ajp/AbstractAjpProtocol.java +++ b/java/org/apache/coyote/ajp/AbstractAjpProtocol.java @@ -231,6 +231,12 @@ public abstract class AbstractAjpProtocol<S> extends AbstractProtocol<S> { } + @Override + public void addSslHostConfig(SSLHostConfig sslHostConfig, boolean replace) { + getLog().warn(sm.getString("ajpprotocol.noSSL", sslHostConfig.getHostName())); + } + + @Override public SSLHostConfig[] findSslHostConfigs() { return new SSLHostConfig[0]; diff --git a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java index 00e4873cd8..cc6988d308 100644 --- a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java +++ b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java @@ -793,6 +793,12 @@ public abstract class AbstractHttp11Protocol<S> extends AbstractProtocol<S> { } + @Override + public void addSslHostConfig(SSLHostConfig sslHostConfig, boolean replace) { + getEndpoint().addSslHostConfig(sslHostConfig, replace); + } + + @Override public SSLHostConfig[] findSslHostConfigs() { return getEndpoint().findSslHostConfigs(); diff --git a/java/org/apache/tomcat/util/net/SSLHostConfig.java b/java/org/apache/tomcat/util/net/SSLHostConfig.java index ae2252529a..b2d944ee9b 100644 --- a/java/org/apache/tomcat/util/net/SSLHostConfig.java +++ b/java/org/apache/tomcat/util/net/SSLHostConfig.java @@ -22,6 +22,8 @@ import java.io.IOException; import java.io.Serializable; import java.security.KeyStore; import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.Date; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; @@ -884,6 +886,30 @@ public class SSLHostConfig implements Serializable { // --------------------------------------------------------- Support methods + public Set<X509Certificate> certificatesExpiringBefore(Date date) { + Set<X509Certificate> result = new HashSet<>(); + Set<SSLHostConfigCertificate> sslHostConfigCertificates = getCertificates(); + for (SSLHostConfigCertificate sslHostConfigCertificate : sslHostConfigCertificates) { + SSLContext sslContext = sslHostConfigCertificate.getSslContext(); + if (sslContext != null) { + String alias = sslHostConfigCertificate.getCertificateKeyAlias(); + if (alias == null) { + alias = SSLUtilBase.DEFAULT_KEY_ALIAS; + } + X509Certificate[] certificates = sslContext.getCertificateChain(alias); + if (certificates != null && certificates.length > 0) { + X509Certificate certificate = certificates[0]; + Date expirationDate = certificate.getNotAfter(); + if (date.after(expirationDate)) { + result.add(certificate); + } + } + } + } + return result; + } + + public static String adjustRelativePath(String path) throws FileNotFoundException { // Empty or null path can't point to anything useful. The assumption is // that the value is deliberately empty / null so leave it that way. diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 0e6dd3e952..8a55546f20 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -107,6 +107,12 @@ <section name="Tomcat 9.0.81 (remm)" rtext="in development"> <subsection name="Catalina"> <changelog> + <add> + <bug>65770</bug>: Provide a lifecycle listener that will automatically + reload TLS configurations a set time before the certificate is due to + expire. This is intended to be used with third-party tools that + regularly renew TLS certificates. (markt) + </add> <fix> Fix handling of an error reading a context descriptor on deployment. (remm) diff --git a/webapps/docs/config/listeners.xml b/webapps/docs/config/listeners.xml index 1ad5c8f24a..b6201c2463 100644 --- a/webapps/docs/config/listeners.xml +++ b/webapps/docs/config/listeners.xml @@ -492,6 +492,43 @@ </subsection> + <subsection name="TLS configuration reload listener - org.apache.catalina.security.TLSCertificateReloadListener"> + + <p>This listener may be used to monitor the expiration dates of TLS + certificates and trigger automatic reloading of the TLS configuration a set + number of days before the TLS certificate expires.</p> + + <p>This listener assumes there is some other process (certbot, cloud + infrastructure, etc) that renews the certificate on a regular basis and + replaces the current certificate with the new one.</p> + + <p>This listener does <b>NOT</b> re-read the Tomcat configuration from + server.xml. If you make changes to server.xml you must restart the Tomcat + process to pick up those changes.</p> + + <p>This listener must only be nested within <a href="server.html">Server</a> + elements.</p> + + <attributes> + + <attribute name="checkPeriod" required="false"> + <p>The time, in seconds, between reloading checks. The periodic process + for <code>LifecycleListener</code> typically runs much more frequently + than this listener requires. This attribute controls the period between + checks. If not specified, a default of 86,400 seconds (24 hours) is + used.</p> + </attribute> + + <attribute name="daysBefore" required="false"> + <p>The number of days before the expiry of a TLS certificate that it is + expected that the new certificate will be in place and the reloading can + be triggered. If not specified, a default of 14 days is used.</p> + </attribute> + + </attributes> + + </subsection> + <subsection name="UserConfig - org.apache.catalina.startup.UserConfig"> <p>The <strong>UserConfig</strong> provides feature of User Web Applications. --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org