diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslMeterBinder.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslMeterBinder.java new file mode 100644 index 00000000000..9e9c93a2cf6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslMeterBinder.java @@ -0,0 +1,272 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.boot.actuate.autoconfigure.ssl; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.MultiGauge.Row; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.TimeGauge; +import io.micrometer.core.instrument.binder.MeterBinder; + +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.info.SslInfo.BundleInfo; +import org.springframework.boot.info.SslInfo.CertificateChainInfo; +import org.springframework.boot.info.SslInfo.CertificateInfo; +import org.springframework.boot.info.SslInfo.CertificateValidityInfo; +import org.springframework.boot.info.SslInfo.CertificateValidityInfo.Status; +import org.springframework.boot.ssl.SslBundles; + +/** + * {@link MeterBinder} which registers the SSL chain validity (soonest to expire + * certificate in the chain) as a {@link TimeGauge}. Also contributes two {@link Gauge + * gauges} to count the valid and invalid chains. + * + * @author Moritz Halbritter + */ +class SslMeterBinder implements MeterBinder { + + private static final String CHAINS_METRIC_NAME = "ssl.chains"; + + private static final String CHAIN_EXPIRY_METRIC_NAME = "ssl.chain.expiry"; + + private final Clock clock; + + private final SslInfo sslInfo; + + private final BundleMetrics bundleMetrics = new BundleMetrics(); + + SslMeterBinder(SslInfo sslInfo, SslBundles sslBundles) { + this(sslInfo, sslBundles, Clock.systemDefaultZone()); + } + + SslMeterBinder(SslInfo sslInfo, SslBundles sslBundles, Clock clock) { + this.clock = clock; + this.sslInfo = sslInfo; + sslBundles.addBundleRegisterHandler((bundleName, ignored) -> onBundleChange(bundleName)); + for (String bundleName : sslBundles.getBundleNames()) { + sslBundles.addBundleUpdateHandler(bundleName, (ignored) -> onBundleChange(bundleName)); + } + } + + private void onBundleChange(String bundleName) { + BundleInfo bundle = this.sslInfo.getBundle(bundleName); + this.bundleMetrics.updateBundle(bundle); + for (MeterRegistry meterRegistry : this.bundleMetrics.getMeterRegistries()) { + createOrUpdateBundleMetrics(meterRegistry, bundle); + } + } + + @Override + public void bindTo(MeterRegistry meterRegistry) { + for (BundleInfo bundle : this.sslInfo.getBundles()) { + createOrUpdateBundleMetrics(meterRegistry, bundle); + } + Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.VALID)) + .tag("status", "valid") + .register(meterRegistry); + Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.EXPIRED)) + .tag("status", "expired") + .register(meterRegistry); + Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.NOT_YET_VALID)) + .tag("status", "not-yet-valid") + .register(meterRegistry); + Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.WILL_EXPIRE_SOON)) + .tag("status", "will-expire-soon") + .register(meterRegistry); + } + + private void createOrUpdateBundleMetrics(MeterRegistry meterRegistry, BundleInfo bundle) { + MultiGauge multiGauge = this.bundleMetrics.getGauge(bundle, meterRegistry); + List> rows = new ArrayList<>(); + for (CertificateChainInfo chain : bundle.getCertificateChains()) { + Row row = createRowForChain(bundle, chain); + if (row != null) { + rows.add(row); + } + } + multiGauge.register(rows, true); + } + + private Row createRowForChain(BundleInfo bundle, CertificateChainInfo chain) { + CertificateInfo leastValidCertificate = chain.getCertificates() + .stream() + .min(Comparator.comparing(CertificateInfo::getValidityEnds)) + .orElse(null); + if (leastValidCertificate == null) { + return null; + } + Tags tags = Tags.of("chain", chain.getAlias(), "bundle", bundle.getName(), "certificate", + leastValidCertificate.getSerialNumber()); + return Row.of(tags, leastValidCertificate, this::getChainExpiry); + } + + private long countChainsByStatus(Status status) { + long count = 0; + for (BundleInfo bundle : this.bundleMetrics.getBundles()) { + for (CertificateChainInfo chain : bundle.getCertificateChains()) { + if (getChainStatus(chain) == status) { + count++; + } + } + } + return count; + } + + private Status getChainStatus(CertificateChainInfo chain) { + EnumSet statuses = EnumSet.noneOf(Status.class); + for (CertificateInfo certificate : chain.getCertificates()) { + CertificateValidityInfo validity = certificate.getValidity(); + statuses.add(validity.getStatus()); + } + if (statuses.contains(Status.EXPIRED)) { + return Status.EXPIRED; + } + if (statuses.contains(Status.NOT_YET_VALID)) { + return Status.NOT_YET_VALID; + } + if (statuses.contains(Status.WILL_EXPIRE_SOON)) { + return Status.WILL_EXPIRE_SOON; + } + return statuses.isEmpty() ? null : Status.VALID; + } + + private long getChainExpiry(CertificateInfo certificate) { + Duration valid = Duration.between(Instant.now(this.clock), certificate.getValidityEnds()); + return valid.get(ChronoUnit.SECONDS); + } + + /** + * Manages bundles and their metrics. + */ + private static final class BundleMetrics { + + private final Map gauges = new ConcurrentHashMap<>(); + + /** + * Gets (or creates) a {@link MultiGauge} for the given bundle and meter registry. + * @param bundleInfo the bundle + * @param meterRegistry the meter registry + * @return the {@link MultiGauge} + */ + MultiGauge getGauge(BundleInfo bundleInfo, MeterRegistry meterRegistry) { + Gauges gauges = this.gauges.computeIfAbsent(bundleInfo.getName(), + (ignored) -> Gauges.emptyGauges(bundleInfo)); + return gauges.getGauge(meterRegistry); + } + + /** + * Returns all bundles. + * @return all bundles + */ + Collection getBundles() { + List result = new ArrayList<>(); + for (Gauges metrics : this.gauges.values()) { + result.add(metrics.bundle()); + } + return result; + } + + /** + * Returns all meter registries. + * @return all meter registries + */ + Collection getMeterRegistries() { + Set result = new HashSet<>(); + for (Gauges metrics : this.gauges.values()) { + result.addAll(metrics.getMeterRegistries()); + } + return result; + } + + /** + * Updates the given bundle. + * @param bundle the updated bundle + */ + void updateBundle(BundleInfo bundle) { + this.gauges.computeIfPresent(bundle.getName(), (key, oldValue) -> oldValue.withBundle(bundle)); + } + + /** + * Manages the {@link MultiGauge MultiGauges} associated to a bundle. + * + * @param bundle the bundle + * @param multiGauges mapping from meter registry to {@link MultiGauge} + */ + private record Gauges(BundleInfo bundle, Map multiGauges) { + + /** + * Gets (or creates) the {@link MultiGauge} for the given meter registry. + * @param meterRegistry the meter registry + * @return the {@link MultiGauge} + */ + MultiGauge getGauge(MeterRegistry meterRegistry) { + return this.multiGauges.computeIfAbsent(meterRegistry, (ignored) -> createGauge(meterRegistry)); + } + + /** + * Returns a copy of this bundle with an updated {@link BundleInfo}. + * @param bundle the updated {@link BundleInfo} + * @return the copy of this bundle with an updated {@link BundleInfo} + */ + Gauges withBundle(BundleInfo bundle) { + return new Gauges(bundle, this.multiGauges); + } + + /** + * Returns all meter registries. + * @return all meter registries + */ + Set getMeterRegistries() { + return this.multiGauges.keySet(); + } + + private MultiGauge createGauge(MeterRegistry meterRegistry) { + return MultiGauge.builder(CHAIN_EXPIRY_METRIC_NAME) + .baseUnit("seconds") + .description("SSL chain expiry") + .register(meterRegistry); + } + + /** + * Creates an instance with an empty gauge mapping. + * @param bundle the {@link BundleInfo} associated with the new instance + * @return the new instance + */ + static Gauges emptyGauges(BundleInfo bundle) { + return new Gauges(bundle, new ConcurrentHashMap<>()); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslObservabilityAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslObservabilityAutoConfiguration.java new file mode 100644 index 00000000000..a763dd0a15c --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslObservabilityAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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.springframework.boot.actuate.autoconfigure.ssl; + +import io.micrometer.core.instrument.MeterRegistry; + +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.context.annotation.Bean; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for SSL observability. + * + * @author Moritz Halbritter + * @since 3.5.0 + */ +@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class, + SslAutoConfiguration.class }) +@ConditionalOnClass(MeterRegistry.class) +@ConditionalOnBean({ MeterRegistry.class, SslBundles.class }) +@EnableConfigurationProperties(SslHealthIndicatorProperties.class) +public class SslObservabilityAutoConfiguration { + + @Bean + SslMeterBinder sslMeterBinder(SslInfo sslInfo, SslBundles sslBundles) { + return new SslMeterBinder(sslInfo, sslBundles); + } + + @Bean + @ConditionalOnMissingBean + SslInfo sslInfoProvider(SslBundles sslBundles, SslHealthIndicatorProperties properties) { + return new SslInfo(sslBundles, properties.getCertificateValidityWarningThreshold()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index c5416d52544..201b9cfdaed 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -104,6 +104,7 @@ org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSec org.springframework.boot.actuate.autoconfigure.session.SessionsEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.startup.StartupEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.ssl.SslHealthContributorAutoConfiguration +org.springframework.boot.actuate.autoconfigure.ssl.SslObservabilityAutoConfiguration org.springframework.boot.actuate.autoconfigure.system.DiskSpaceHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslMeterBinderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslMeterBinderTests.java new file mode 100644 index 00000000000..7f630c03d84 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslMeterBinderTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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.springframework.boot.actuate.autoconfigure.ssl; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslStoreBundle; +import org.springframework.boot.ssl.jks.JksSslStoreBundle; +import org.springframework.boot.ssl.jks.JksSslStoreDetails; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SslMeterBinder}. + * + * @author Moritz Halbritter + */ +class SslMeterBinderTests { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2024-10-21T13:51:40Z"), ZoneId.of("UTC")); + + @Test + void shouldRegisterChainMetrics() { + MeterRegistry meterRegistry = bindToRegistry(); + assertThat(meterRegistry.get("ssl.chains").tag("status", "valid").gauge().value()).isEqualTo(3.0); + assertThat(meterRegistry.get("ssl.chains").tag("status", "expired").gauge().value()).isEqualTo(1.0); + assertThat(meterRegistry.get("ssl.chains").tag("status", "not-yet-valid").gauge().value()).isEqualTo(1.0); + assertThat(meterRegistry.get("ssl.chains").tag("status", "will-expire-soon").gauge().value()).isEqualTo(0.0); + } + + @Test + void shouldRegisterChainExpiryMetrics() { + MeterRegistry meterRegistry = bindToRegistry(); + assertThat(Duration.ofSeconds(findExpiryGauge(meterRegistry, "ca", "419224ce190242b2c44069dd3c560192b3b669f3"))) + .hasDays(1095); + assertThat(Duration + .ofSeconds(findExpiryGauge(meterRegistry, "intermediary", "60f79365fc46bf69149754d377680192b3b6bcf5"))) + .hasDays(730); + assertThat(Duration + .ofSeconds(findExpiryGauge(meterRegistry, "server", "504c45129526ac050abb11459b1f0192b3b70fe9"))) + .hasDays(365); + assertThat(Duration + .ofSeconds(findExpiryGauge(meterRegistry, "expired", "562bc5dcf4f26bb179abb13068180192b3bb53dc"))) + .hasDays(-386); + assertThat(Duration + .ofSeconds(findExpiryGauge(meterRegistry, "not-yet-valid", "7df79335f274e2cfa7467fd5f9ce0192b3bcf4aa"))) + .hasDays(36889); + } + + private static long findExpiryGauge(MeterRegistry meterRegistry, String chain, String certificateSerialNumber) { + return (long) meterRegistry.get("ssl.chain.expiry") + .tag("bundle", "test-0") + .tag("chain", chain) + .tag("certificate", certificateSerialNumber) + .gauge() + .value(); + } + + private SimpleMeterRegistry bindToRegistry() { + SslBundles sslBundles = createSslBundles("classpath:certificates/chains.p12"); + SslInfo sslInfo = createSslInfo(sslBundles); + SslMeterBinder binder = new SslMeterBinder(sslInfo, sslBundles, CLOCK); + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + binder.bindTo(meterRegistry); + return meterRegistry; + } + + private SslBundles createSslBundles(String... locations) { + DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); + for (int i = 0; i < locations.length; i++) { + JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation(locations[i]).withPassword("secret"); + SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, null); + sslBundleRegistry.registerBundle("test-%d".formatted(i), SslBundle.of(sslStoreBundle)); + } + return sslBundleRegistry; + } + + private SslInfo createSslInfo(SslBundles sslBundles) { + return new SslInfo(sslBundles, Duration.ofDays(7), CLOCK); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslObservabilityAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslObservabilityAutoConfigurationTests.java new file mode 100644 index 00000000000..39cb14182d1 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslObservabilityAutoConfigurationTests.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * 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 + * + * https://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.springframework.boot.actuate.autoconfigure.ssl; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SslObservabilityAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class SslObservabilityAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class, + SslAutoConfiguration.class, SslObservabilityAutoConfiguration.class)); + + private final ApplicationContextRunner contextRunnerWithoutSslBundles = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MetricsAutoConfiguration.class, + CompositeMeterRegistryAutoConfiguration.class, SslObservabilityAutoConfiguration.class)); + + private final ApplicationContextRunner contextRunnerWithoutMeterRegistry = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SslAutoConfiguration.class, SslObservabilityAutoConfiguration.class)); + + @Test + void shouldSupplyBeans() { + this.contextRunner + .run((context) -> assertThat(context).hasSingleBean(SslMeterBinder.class).hasSingleBean(SslInfo.class)); + } + + @Test + void shouldBackOffIfSslBundlesIsMissing() { + this.contextRunnerWithoutSslBundles + .run((context) -> assertThat(context).doesNotHaveBean(SslMeterBinder.class).doesNotHaveBean(SslInfo.class)); + } + + @Test + void shouldBackOffIfMeterRegistryIsMissing() { + this.contextRunnerWithoutMeterRegistry + .run((context) -> assertThat(context).doesNotHaveBean(SslMeterBinder.class).doesNotHaveBean(SslInfo.class)); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/certificates/chains.p12 b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/certificates/chains.p12 new file mode 100644 index 00000000000..b0a8d29a2b7 Binary files /dev/null and b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/certificates/chains.p12 differ diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/metrics.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/metrics.adoc index b2b1044c0c2..716b0c6be3e 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/metrics.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/metrics.adoc @@ -865,6 +865,33 @@ To customize the tags, provide a javadoc:org.springframework.context.annotation. +[[actuator.metrics.supported.ssl]] +=== SSL bundle metrics + +Spring Boot Actuator publishes two metrics about SSL bundles: + +The metric `ssl.chains` gauges how many certificate chains have been registered. +The `status` tag can be used to differentiate between valid, not-yet-valid, expired and soon-to-be-expired certificates. + +The metric `ssl.chain.expiry` gauges the expiry date of each certificate chain in seconds. +This number will be negative if the chain has already expired. +This metric is tagged with the following information: + +|=== +| Tag | Description + +| `bundle` +| The name of the bundle which contains the certificate chain + +| `certificate` +| The serial number (in hex format) of the certificate which is the soonest to expire in the chain + +| `chain` +| The name of the certificate chain. +|=== + + + [[actuator.metrics.supported.http-clients]] === HTTP Client Metrics diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java index c88eb43a288..7f93074df9b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java @@ -22,6 +22,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateExpiredException; import java.security.cert.CertificateNotYetValidException; import java.security.cert.X509Certificate; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -33,6 +34,7 @@ import java.util.function.Function; import javax.security.auth.x500.X500Principal; import org.springframework.boot.info.SslInfo.CertificateValidityInfo.Status; +import org.springframework.boot.ssl.NoSuchSslBundleException; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.util.ObjectUtils; @@ -41,6 +43,7 @@ import org.springframework.util.ObjectUtils; * Information about the certificates that the application uses. * * @author Jonatan Ivanov + * @author Moritz Halbritter * @since 3.4.0 */ public class SslInfo { @@ -49,11 +52,36 @@ public class SslInfo { private final Duration certificateValidityWarningThreshold; + private final Clock clock; + + /** + * Creates a new instance. + * @param sslBundles the {@link SslBundles} to extract the info from + * @param certificateValidityWarningThreshold the certificate validity warning + * threshold + */ public SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold) { - this.sslBundles = sslBundles; - this.certificateValidityWarningThreshold = certificateValidityWarningThreshold; + this(sslBundles, certificateValidityWarningThreshold, Clock.systemDefaultZone()); } + /** + * Creates a new instance. + * @param sslBundles the {@link SslBundles} to extract the info from + * @param certificateValidityWarningThreshold the certificate validity warning + * threshold + * @param clock the {@link Clock} to use + * @since 3.5.0 + */ + public SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold, Clock clock) { + this.sslBundles = sslBundles; + this.certificateValidityWarningThreshold = certificateValidityWarningThreshold; + this.clock = clock; + } + + /** + * Returns information on all SSL bundles. + * @return information on all SSL bundles + */ public List getBundles() { return this.sslBundles.getBundleNames() .stream() @@ -61,6 +89,18 @@ public class SslInfo { .toList(); } + /** + * Returns an SSL bundle by name. + * @param name the name of the SSL bundle + * @return the {@link BundleInfo} for the given SSL bundle + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + * @since 3.5.0 + */ + public BundleInfo getBundle(String name) { + SslBundle bundle = this.sslBundles.getBundle(name); + return new BundleInfo(name, bundle); + } + /** * Info about a single {@link SslBundle}. */ @@ -195,7 +235,7 @@ public class SslInfo { } private boolean isExpiringSoon(X509Certificate certificate, Duration threshold) { - Instant shouldBeValidAt = Instant.now().plus(threshold); + Instant shouldBeValidAt = Instant.now(SslInfo.this.clock).plus(threshold); Instant expiresAt = certificate.getNotAfter().toInstant(); return shouldBeValidAt.isAfter(expiresAt); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java index 0cbe28f0d3e..55f2a66addb 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; import java.util.function.Consumer; import org.apache.commons.logging.Log; @@ -45,6 +46,8 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { private final Map registeredBundles = new ConcurrentHashMap<>(); + private final List> registerHandlers = new CopyOnWriteArrayList<>(); + public DefaultSslBundleRegistry() { } @@ -58,6 +61,7 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { Assert.notNull(bundle, "'bundle' must not be null"); RegisteredSslBundle previous = this.registeredBundles.putIfAbsent(name, new RegisteredSslBundle(name, bundle)); Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name)); + this.registerHandlers.forEach((handler) -> handler.accept(name, bundle)); } @Override @@ -75,6 +79,11 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { getRegistered(name).addUpdateHandler(updateHandler); } + @Override + public void addBundleRegisterHandler(BiConsumer registerHandler) { + this.registerHandlers.add(registerHandler); + } + @Override public List getBundleNames() { List names = new ArrayList<>(this.registeredBundles.keySet()); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java index 91bce68fdc0..10fe2f9130a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java @@ -17,6 +17,7 @@ package org.springframework.boot.ssl; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Consumer; /** @@ -46,6 +47,14 @@ public interface SslBundles { */ void addBundleUpdateHandler(String name, Consumer updateHandler) throws NoSuchSslBundleException; + /** + * Add a handler that will be called each time a bundle is registered. The handler + * will be called with the bundle name and the bundle. + * @param registerHandler the handler that should be called + * @since 3.5.0 + */ + void addBundleRegisterHandler(BiConsumer registerHandler); + /** * Return the names of all bundles managed by this instance. * @return the bundle names