Merge "Add certificates expiration date checker"
diff --git a/admin/certificates-validity-checker-1.0.groovy b/admin/certificates-validity-checker-1.0.groovy
new file mode 100644
index 0000000..841efdf
--- /dev/null
+++ b/admin/certificates-validity-checker-1.0.groovy
@@ -0,0 +1,186 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed 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.
+
+import com.google.common.base.Strings
+import com.google.common.flogger.FluentLogger
+import com.google.gerrit.extensions.annotations.Listen
+import com.google.gerrit.extensions.annotations.PluginName
+import com.google.gerrit.extensions.events.LifecycleListener
+import com.google.gerrit.metrics.CallbackMetric1
+import com.google.gerrit.metrics.Description
+import com.google.gerrit.metrics.Field
+import com.google.gerrit.metrics.MetricMaker
+import com.google.gerrit.server.config.ConfigUtil
+import com.google.gerrit.server.config.PluginConfigFactory
+import com.google.gerrit.server.git.WorkQueue
+import com.google.gerrit.server.logging.Metadata
+import com.google.inject.Inject
+import com.google.inject.Singleton
+import sun.security.x509.GeneralNameInterface
+
+import javax.net.ssl.SSLSocket
+import javax.net.ssl.SSLSocketFactory
+import java.security.cert.Certificate
+import java.security.cert.X509Certificate
+import java.time.Duration
+import java.util.concurrent.ScheduledFuture
+
+import static java.util.concurrent.TimeUnit.HOURS
+import static java.util.concurrent.TimeUnit.MILLISECONDS
+import static java.util.concurrent.TimeUnit.SECONDS
+
+@Singleton
+@Listen
+class CertificatesValidityChecker implements LifecycleListener {
+  private static final int DEFAULT_CHECK_INTERVAL_HOURS = 24
+  private final WorkQueue queue
+  private final PluginConfigFactory config
+  private final String pluginName
+  private final CertificatesCheckMetrics metrics
+
+  private ScheduledFuture<?> certificatesValidityChecksTask
+  private List<String> endpoints
+  private Long checkIntervalInMillis
+
+  @Inject
+  CertificatesValidityChecker(WorkQueue queue, PluginConfigFactory cfg,
+                                   CertificatesCheckMetrics metrics,
+                                   @PluginName String pluginName) {
+    this.metrics = metrics
+    this.queue = queue
+    this.config = cfg
+    this.pluginName = pluginName
+  }
+
+  @Override
+  void start() {
+    endpoints = getEndpointsList(config, pluginName)
+    checkIntervalInMillis = getCheckIntervalMillis(config, pluginName)
+    certificatesValidityChecksTask = queue.getDefaultQueue()
+        .scheduleAtFixedRate(
+            new CheckCertificatesValidityTask(metrics, endpoints),
+            SECONDS.toMillis(1),
+            checkIntervalInMillis,
+            MILLISECONDS)
+  }
+
+  @Override
+  void stop() {
+    if (certificatesValidityChecksTask != null) {
+      certificatesValidityChecksTask.cancel(true)
+      certificatesValidityChecksTask = null
+    }
+  }
+
+  private Long getCheckIntervalMillis(PluginConfigFactory cfg, String pluginName) {
+    String fromConfig =
+        Strings.nullToEmpty(cfg.getGlobalPluginConfig(pluginName).getString("validation",null,"checkInterval"))
+    return HOURS.toMillis(ConfigUtil.getTimeUnit(fromConfig, DEFAULT_CHECK_INTERVAL_HOURS, HOURS))
+  }
+
+  private List<String> getEndpointsList(PluginConfigFactory cfg, String pluginName) {
+    return cfg.getGlobalPluginConfig(pluginName).getStringList("validation",null,"endpoint")
+  }
+
+  private static class CertificatesCheckMetrics {
+    private static final Field<String> ENDPOINT_NAME =
+        Field.ofString("endpoint_name", Metadata.Builder.&cacheName).build()
+    private final CallbackMetric1<String, Integer> metrics
+
+    @Inject
+    CertificatesCheckMetrics(MetricMaker metricMaker) {
+      this.metrics =
+          metricMaker.newCallbackMetric(
+              "certificates/number_of_day_to_expire/per_endpoint",
+              Integer.class,
+              new Description("Per-endpoint certificate expiration date")
+                  .setGauge()
+                  .setUnit("days"),
+              ENDPOINT_NAME)
+    }
+
+    def setMetric(String endpoint, int numberOfDays) {
+      metrics.set(endpoint, numberOfDays)
+    }
+  }
+
+  private static class CheckCertificatesValidityTask implements Runnable {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass()
+    private final CertificatesCheckMetrics metrics
+    private final List<String> endpoints
+
+    CheckCertificatesValidityTask(CertificatesCheckMetrics metrics, List<String> endpoints) {
+      this.endpoints = endpoints
+      this.metrics = metrics
+    }
+
+    @Override
+    void run() {
+      for (String endpoint : endpoints) {
+        logger.atInfo().log("Checking certificate expiry date for %s endpoint", endpoint)
+        SSLSocket conn
+        try {
+          def (hostname, port) = parseEndpoint(endpoint)
+          conn = openConnection(hostname as String, port as int)
+          conn.startHandshake();
+          Certificate[] certs = conn.getSession().getPeerCertificates();
+          for (Certificate cert : certs) {
+            if (cert instanceof X509Certificate &&
+              cert.getSubjectAlternativeNames().findAll{it[0] == GeneralNameInterface.NAME_DNS}
+                  .any {isHostnameMatching(hostname as String, it.get(1) as String) }) {
+              def numberOfDaysToExpire = Duration
+                  .between(new Date().toInstant(), cert.notAfter.toInstant()).toDays()
+              metrics
+                  .setMetric(
+                      hostname as String,
+                      numberOfDaysToExpire.intValue())
+            } else {
+              logger.atFine().log("Certificate type %s is not a valid X.509 certificate for the specified endpoint: %s. Skipping!", cert.getType(), endpoint)
+            }
+          }
+        } catch(e) {
+          logger.atSevere()
+              .withCause(e)
+              .log("Cannot check certificates expiry date for %s endpoint", endpoint)
+        } finally {
+            conn?.close()
+        }
+      }
+    }
+
+    def parseEndpoint(String endpoint) {
+      def hostAndPort = endpoint.split(':')
+      if (hostAndPort.size() != 2) {
+        throw new IllegalArgumentException("Wrong endpoint format, expected <host>:<port> but was ${endpoint}")
+      }
+
+      hostAndPort
+    }
+
+    private boolean isHostnameMatching(String hostname, String certName) {
+      // Replace the wildcard (*) with a regex wildcard (.*)
+      def certPattern = certName.replaceFirst("\\*", ".*")
+      return hostname.matches(certPattern)
+    }
+
+    private SSLSocket openConnection(String hostname, int port) {
+      logger
+          .atInfo()
+          .log("Opening connection for %s endpoint successful",
+              hostname)
+      (SSLSocket) SSLSocketFactory.getDefault()
+          .createSocket(hostname, port);
+    }
+  }
+}
diff --git a/admin/certificates-validity-checker.md b/admin/certificates-validity-checker.md
new file mode 100644
index 0000000..1f0b374
--- /dev/null
+++ b/admin/certificates-validity-checker.md
@@ -0,0 +1,46 @@
+Certificates Validity Check utility
+==============================
+
+DESCRIPTION
+-----------
+Check SSL Certificates expiry date and expose them as a Gerrit metrics.
+
+Configuration
+=========================
+
+The certificates-validity-checker plugin is configured in
+$site_path/etc/@PLUGIN@.config` files, example:
+
+```text
+[validation]
+        endpoint = hostname:443
+        endpoint = mail.hostname.com:993
+        checkInterval = 1 day
+```
+
+Configuration parameters
+---------------------
+
+```validation.checkInterval```
+:  Frequency of the SSL certificates expiry date check operation
+   Value should use common time unit suffixes to express their setting:
+   * h, hr, hour, hours
+   * d, day, days
+   * w, week, weeks (`1 week` is treated as `7 days`)
+   * mon, month, months (`1 month` is treated as `30 days`)
+   * y, year, years (`1 year` is treated as `365 days`)
+   If a time unit suffix is not specified, `hours` is assumed.
+   Time intervals smaller than one hour are not supported.
+   Default: 24 hours
+
+```validation.endpoint```
+:  Specify for which endpoint SSL certificates expiry date should be
+   checked and expose as a Gerrit metric.
+   Endpoint format is <host>:<port>
+   It can be provided more than once.
+
+Metrics
+---------------------
+Currently, the metrics exposed are the following:
+
+```groovy_certificates_validity_checker_certificates_number_of_day_to_expire_per_endpoint_<hostname>```