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

Reply via email to