| // 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 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.time.Instant | |
| 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 FluentLogger logger = FluentLogger.forEnclosingClass() | |
| private static final int DEFAULT_CHECK_INTERVAL_HOURS = 24 | |
| private static final int DEFAULT_CHECK_TIMEOUT_MSEC = 1000 | |
| private final WorkQueue queue | |
| private final PluginConfigFactory config | |
| private final String pluginName | |
| private final CertificatesCheckMetrics metrics | |
| private ScheduledFuture<?> certificatesValidityChecksTask | |
| private List<String> endpoints | |
| private boolean started | |
| @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() { | |
| if (started) { | |
| logger.atWarning().log("Plugin already started: ignoring duplicate start") | |
| return | |
| } | |
| endpoints = getEndpointsList(config, pluginName) | |
| long checkIntervalInHours = getCheckIntervalHours(config, pluginName) | |
| int timeout = config.getGlobalPluginConfig(pluginName).getInt("validation",null,"timeout", DEFAULT_CHECK_TIMEOUT_MSEC) | |
| logger.atInfo().log("Checking certificates expiry date every %d hours (timeout=%d msec) for %s", | |
| checkIntervalInHours, timeout, endpoints) | |
| certificatesValidityChecksTask = queue.getDefaultQueue() | |
| .scheduleAtFixedRate( | |
| new CheckCertificatesValidityTask(metrics, endpoints, timeout), | |
| SECONDS.toMillis(1), | |
| HOURS.toMillis(checkIntervalInHours), | |
| MILLISECONDS) | |
| started = true | |
| } | |
| @Override | |
| void stop() { | |
| if (certificatesValidityChecksTask != null) { | |
| certificatesValidityChecksTask.cancel(true) | |
| certificatesValidityChecksTask = null | |
| } | |
| started = false | |
| } | |
| private Long getCheckIntervalHours(PluginConfigFactory cfg, String pluginName) { | |
| String fromConfig = | |
| Strings.nullToEmpty(cfg.getGlobalPluginConfig(pluginName).getString("validation",null,"checkInterval")) | |
| return 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 static final int DNS_TYPE = 2 // The GeneralNameInterface.NAME_DNS value, inaccessible because of being an internal package | |
| private final CertificatesCheckMetrics metrics | |
| private final List<String> endpoints | |
| private final int timeout | |
| CheckCertificatesValidityTask(CertificatesCheckMetrics metrics, List<String> endpoints, int timeout) { | |
| this.endpoints = endpoints | |
| this.metrics = metrics | |
| this.timeout = timeout | |
| } | |
| @Override | |
| void run() { | |
| for (String endpoint : endpoints) { | |
| logger.atFine().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.setSoTimeout(timeout) | |
| conn.startHandshake() | |
| X509Certificate[] certs = conn.getSession().getPeerCertificates().findAll {it instanceof X509Certificate} | |
| for (X509Certificate cert : certs) { | |
| def certsAlternativeNames = cert.subjectAlternativeNames | |
| def certsDnsNames = certsAlternativeNames.findAll{it[0] == DNS_TYPE}.collect{it[1]} | |
| if (certsDnsNames.empty) { | |
| logger.atFine().log("[%s] Skipping X.509 Certificate %s because there are no subjectAlternativeNames of DNS type", endpoint, cert) | |
| continue | |
| } | |
| logger.atFine().log("[%s] Checking X.509 DNS names %s against %s:%d", endpoint, certsDnsNames, hostname, port) | |
| def anyCertIsMatchingDnsNames = certsDnsNames.any{isHostnameMatching(hostname as String, it as String)} | |
| if (!anyCertIsMatchingDnsNames) { | |
| logger.atWarning().log("[%s] Skipping X.509 Certificate %s because none of the certificate DNS names %s are matching the hostname %s", endpoint, cert, certsDnsNames, hostname) | |
| continue | |
| } | |
| logger.atFine().log("[%s] X.509 Certificate %s has expiry date %s", endpoint, cert.subjectDN, cert.notAfter) | |
| def numberOfDaysToExpire = Duration | |
| .between(Instant.now(), cert.notAfter.toInstant()).toDays() | |
| metrics | |
| .setMetric( | |
| hostname as String, | |
| numberOfDaysToExpire.intValue()) | |
| if (numberOfDaysToExpire >= 0) { | |
| logger.atInfo().log("[%s] X.509 Certificate %s is valid and has %d days left", endpoint, cert.subjectDN, numberOfDaysToExpire) | |
| } else { | |
| logger.atWarning().log("[%s] X.509 Certificate for %s **EXPIRED**", endpoint, cert.subjectDN) | |
| } | |
| } | |
| } 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) { | |
| (SSLSocket) SSLSocketFactory.getDefault() | |
| .createSocket(hostname, port); | |
| } | |
| } | |
| } |