From 5e3796e814a20fe79b70a1cce66ab2916cc778dc Mon Sep 17 00:00:00 2001 From: Jonatan Ivanov Date: Thu, 16 Nov 2023 15:33:57 -0800 Subject: [PATCH] Add SslInfoContributor and SslHealthIndicator See gh-41205 --- .../InfoContributorAutoConfiguration.java | 20 +- ...SslHealthContributorAutoConfiguration.java | 53 ++++ .../ssl/SslHealthIndicatorProperties.java | 47 +++ .../autoconfigure/ssl/package-info.java | 20 ++ ...itional-spring-configuration-metadata.json | 18 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...InfoContributorAutoConfigurationTests.java | 67 ++++- ...althContributorAutoConfigurationTests.java | 137 +++++++++ .../boot/actuate/info/SslInfoContributor.java | 58 ++++ .../boot/actuate/ssl/SslHealthIndicator.java | 88 ++++++ .../boot/actuate/ssl/package-info.java | 20 ++ .../actuate/info/SslInfoContributorTests.java | 63 ++++ .../actuate/ssl/SslHealthIndicatorTests.java | 124 ++++++++ .../reference/pages/actuator/endpoints.adoc | 22 +- .../springframework/boot/info/SslInfo.java | 243 ++++++++++++++++ .../boot/ssl/DefaultSslBundleRegistry.java | 12 +- .../springframework/boot/ssl/SslBundles.java | 11 +- .../boot/info/SslInfoTests.java | 268 ++++++++++++++++++ .../ssl/DefaultSslBundleRegistryTests.java | 9 + .../src/test/resources/test-expired.p12 | Bin 0 -> 2802 bytes .../src/test/resources/test-not-yet-valid.p12 | Bin 0 -> 2802 bytes .../build.gradle | 1 + .../src/main/resources/application.properties | 17 +- .../ssl/SampleTomcatSslApplicationTests.java | 40 +++ 24 files changed, 1330 insertions(+), 9 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthIndicatorProperties.java create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/package-info.java create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/SslInfoContributor.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/SslHealthIndicator.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/package-info.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/SslInfoContributorTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/ssl/SslHealthIndicatorTests.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java create mode 100644 spring-boot-project/spring-boot/src/test/resources/test-expired.p12 create mode 100644 spring-boot-project/spring-boot/src/test/resources/test-not-yet-valid.p12 diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java index b26455ad43ab..a1c467a5bb65 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.info; +import org.springframework.boot.actuate.autoconfigure.ssl.SslHealthIndicatorProperties; import org.springframework.boot.actuate.info.BuildInfoContributor; import org.springframework.boot.actuate.info.EnvironmentInfoContributor; import org.springframework.boot.actuate.info.GitInfoContributor; @@ -23,6 +24,7 @@ import org.springframework.boot.actuate.info.JavaInfoContributor; import org.springframework.boot.actuate.info.OsInfoContributor; import org.springframework.boot.actuate.info.ProcessInfoContributor; +import org.springframework.boot.actuate.info.SslInfoContributor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -31,6 +33,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.GitProperties; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -46,7 +50,7 @@ * @since 2.0.0 */ @AutoConfiguration(after = ProjectInfoAutoConfiguration.class) -@EnableConfigurationProperties(InfoContributorProperties.class) +@EnableConfigurationProperties({ InfoContributorProperties.class, SslHealthIndicatorProperties.class }) public class InfoContributorAutoConfiguration { /** @@ -100,4 +104,18 @@ public ProcessInfoContributor processInfoContributor() { return new ProcessInfoContributor(); } + @Bean + @ConditionalOnEnabledInfoContributor(value = "ssl", fallback = InfoContributorFallback.DISABLE) + @Order(DEFAULT_ORDER) + public SslInfoContributor sslInfoContributor(SslInfo sslInfo) { + return new SslInfoContributor(sslInfo); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledInfoContributor(value = "ssl", fallback = InfoContributorFallback.DISABLE) + public SslInfo sslInfo(SslBundles sslBundles, SslHealthIndicatorProperties sslHealthIndicatorProperties) { + return new SslInfo(sslBundles, sslHealthIndicatorProperties.getCertificateValidityWarningThreshold()); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfiguration.java new file mode 100644 index 000000000000..e431611843a6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfiguration.java @@ -0,0 +1,53 @@ +/* + * 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.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration; +import org.springframework.boot.actuate.ssl.SslHealthIndicator; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +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 {@link SslHealthIndicator}. + * + * @author Jonatan Ivanov + * @since 3.4.0 + */ +@AutoConfiguration(before = HealthContributorAutoConfiguration.class) +@ConditionalOnEnabledHealthIndicator("ssl") +@EnableConfigurationProperties(SslHealthIndicatorProperties.class) +public class SslHealthContributorAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "sslHealthIndicator") + public SslHealthIndicator sslHealthIndicator(SslInfo sslInfo) { + return new SslHealthIndicator(sslInfo); + } + + @Bean + @ConditionalOnMissingBean + public SslInfo sslInfo(SslBundles sslBundles, SslHealthIndicatorProperties sslHealthIndicatorProperties) { + return new SslInfo(sslBundles, sslHealthIndicatorProperties.getCertificateValidityWarningThreshold()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthIndicatorProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthIndicatorProperties.java new file mode 100644 index 000000000000..eb897d70eb09 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthIndicatorProperties.java @@ -0,0 +1,47 @@ +/* + * 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.Duration; + +import org.springframework.boot.actuate.ssl.SslHealthIndicator; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * External configuration properties for {@link SslHealthIndicator}. + * + * @author Jonatan Ivanov + * @since 3.4.0 + */ +@ConfigurationProperties(prefix = "management.health.ssl") +public class SslHealthIndicatorProperties { + + /** + * If an SSL Certificate will be invalid within the time span defined by this + * threshold, it should trigger a warning. + */ + private Duration certificateValidityWarningThreshold = Duration.ofDays(14); + + public Duration getCertificateValidityWarningThreshold() { + return this.certificateValidityWarningThreshold; + } + + public void setCertificateValidityWarningThreshold(Duration certificateValidityWarningThreshold) { + this.certificateValidityWarningThreshold = certificateValidityWarningThreshold; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/package-info.java new file mode 100644 index 000000000000..bfeaa736f6bf --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/ssl/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Auto-configuration for actuator ssl concerns. + */ +package org.springframework.boot.actuate.autoconfigure.ssl; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 15f80b792845..0bda4c7906ce 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -227,6 +227,18 @@ "description": "Whether to enable Redis health check.", "defaultValue": true }, + { + "name": "management.health.ssl.certificate-validity-warning-threshold", + "type": "java.time.Duration", + "description": "If an SSL Certificate will be invalid within the time span defined by this threshold, it should trigger a warning.", + "defaultValue": "14d" + }, + { + "name": "management.health.ssl.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable SSL Certificate health check.", + "defaultValue": true + }, { "name": "management.httpexchanges.recording.enabled", "type": "java.lang.Boolean", @@ -283,6 +295,12 @@ "description": "Whether to enable process info.", "defaultValue": false }, + { + "name": "management.info.ssl.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable SSL Certificate info.", + "defaultValue": false + }, { "name": "management.metrics.binders.files.enabled", "type": "java.lang.Boolean", 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 2a382e63bb31..5bcd3472fb05 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.reactive.ReactiveManagem org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration 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.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/info/InfoContributorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java index c9e5d73b5bdb..dc276ab5c617 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java @@ -16,11 +16,13 @@ package org.springframework.boot.actuate.autoconfigure.info; +import java.time.Duration; import java.util.Map; import java.util.Properties; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.ssl.SslHealthIndicatorProperties; import org.springframework.boot.actuate.info.BuildInfoContributor; import org.springframework.boot.actuate.info.EnvironmentInfoContributor; import org.springframework.boot.actuate.info.GitInfoContributor; @@ -29,12 +31,16 @@ import org.springframework.boot.actuate.info.JavaInfoContributor; import org.springframework.boot.actuate.info.OsInfoContributor; import org.springframework.boot.actuate.info.ProcessInfoContributor; +import org.springframework.boot.actuate.info.SslInfoContributor; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.GitProperties; import org.springframework.boot.info.JavaInfo; import org.springframework.boot.info.OsInfo; import org.springframework.boot.info.ProcessInfo; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -60,7 +66,8 @@ void envContributor() { @Test void defaultInfoContributorsEnabled() { - this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(InfoContributor.class)); + this.contextRunner.run( + (context) -> assertThat(context).doesNotHaveBean(InfoContributor.class).doesNotHaveBean(SslInfo.class)); } @Test @@ -176,6 +183,54 @@ void processInfoContributor() { }); } + @Test + void sslInfoContributor() { + this.contextRunner.withConfiguration(AutoConfigurations.of(SslAutoConfiguration.class)) + .withPropertyValues("management.info.ssl.enabled=true", "server.ssl.bundle=ssltest", + "spring.ssl.bundle.jks.ssltest.keystore.location=classpath:test.jks") + .run((context) -> { + assertThat(context).hasSingleBean(SslInfoContributor.class); + assertThat(context).hasSingleBean(SslInfo.class); + Map content = invokeContributor(context.getBean(SslInfoContributor.class)); + assertThat(content).containsKey("ssl"); + assertThat(content.get("ssl")).isInstanceOf(SslInfo.class); + }); + } + + @Test + void sslInfoContributorWithWarningThreshold() { + this.contextRunner.withConfiguration(AutoConfigurations.of(SslAutoConfiguration.class)) + .withPropertyValues("management.info.ssl.enabled=true", "server.ssl.bundle=ssltest", + "spring.ssl.bundle.jks.ssltest.keystore.location=classpath:test.jks", + "management.health.ssl.certificate-validity-warning-threshold=1d") + .run((context) -> { + assertThat(context).hasSingleBean(SslInfoContributor.class); + assertThat(context).hasSingleBean(SslInfo.class); + assertThat(context).hasSingleBean(SslHealthIndicatorProperties.class); + assertThat(context.getBean(SslHealthIndicatorProperties.class).getCertificateValidityWarningThreshold()) + .isEqualTo(Duration.ofDays(1)); + Map content = invokeContributor(context.getBean(SslInfoContributor.class)); + assertThat(content).containsKey("ssl"); + assertThat(content.get("ssl")).isInstanceOf(SslInfo.class); + }); + } + + @Test + void customSslInfo() { + this.contextRunner.withUserConfiguration(CustomSslInfoConfiguration.class) + .withConfiguration(AutoConfigurations.of(SslAutoConfiguration.class)) + .withPropertyValues("management.info.ssl.enabled=true", "server.ssl.bundle=ssltest", + "spring.ssl.bundle.jks.ssltest.keystore.location=classpath:test.jks") + .run((context) -> { + assertThat(context).hasSingleBean(SslInfoContributor.class); + assertThat(context).hasSingleBean(SslInfo.class); + assertThat(context.getBean(SslInfo.class)).isSameAs(context.getBean("customSslInfo")); + Map content = invokeContributor(context.getBean(SslInfoContributor.class)); + assertThat(content).containsKey("ssl"); + assertThat(content.get("ssl")).isInstanceOf(SslInfo.class); + }); + } + private Map invokeContributor(InfoContributor contributor) { Info.Builder builder = new Info.Builder(); contributor.contribute(builder); @@ -241,4 +296,14 @@ BuildInfoContributor customBuildInfoContributor() { } + @Configuration(proxyBeanMethods = false) + static class CustomSslInfoConfiguration { + + @Bean + SslInfo customSslInfo(SslBundles sslBundles) { + return new SslInfo(sslBundles, Duration.ofDays(7)); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfigurationTests.java new file mode 100644 index 000000000000..9458b4e1cc40 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfigurationTests.java @@ -0,0 +1,137 @@ +/* + * 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.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.ssl.SslHealthContributorAutoConfigurationTests.CustomSslInfoConfiguration.CustomSslHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.actuate.ssl.SslHealthIndicator; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.info.SslInfo.CertificateChain; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SslHealthContributorAutoConfiguration}. + */ +class SslHealthContributorAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(SslHealthContributorAutoConfiguration.class, SslAutoConfiguration.class)) + .withPropertyValues("server.ssl.bundle=ssltest", + "spring.ssl.bundle.jks.ssltest.keystore.location=classpath:test.jks"); + + @Test + void beansShouldNotBeConfigured() { + this.contextRunner.withPropertyValues("management.health.ssl.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(HealthIndicator.class) + .doesNotHaveBean(SslInfo.class)); + } + + @Test + @SuppressWarnings("unchecked") + void beansShouldBeConfigured() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(SslHealthIndicator.class); + assertThat(context).hasSingleBean(SslInfo.class); + Health health = context.getBean(SslHealthIndicator.class).health(); + assertThat(health.getStatus()).isSameAs(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + + }); + } + + @Test + @SuppressWarnings("unchecked") + void beansShouldBeConfiguredWithWarningThreshold() { + this.contextRunner.withPropertyValues("management.health.ssl.certificate-validity-warning-threshold=1d") + .run((context) -> { + assertThat(context).hasSingleBean(SslHealthIndicator.class); + assertThat(context).hasSingleBean(SslInfo.class); + assertThat(context).hasSingleBean(SslHealthIndicatorProperties.class); + assertThat(context.getBean(SslHealthIndicatorProperties.class).getCertificateValidityWarningThreshold()) + .isEqualTo(Duration.ofDays(1)); + Health health = context.getBean(SslHealthIndicator.class).health(); + assertThat(health.getStatus()).isSameAs(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + }); + } + + @Test + @SuppressWarnings("unchecked") + void customBeansShouldBeConfigured() { + this.contextRunner.withUserConfiguration(CustomSslInfoConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(SslHealthIndicator.class); + assertThat(context.getBean(SslHealthIndicator.class)) + .isSameAs(context.getBean(CustomSslHealthIndicator.class)); + assertThat(context).hasSingleBean(SslInfo.class); + assertThat(context.getBean(SslInfo.class)).isSameAs(context.getBean("customSslInfo")); + Health health = context.getBean(SslHealthIndicator.class).health(); + assertThat(health.getStatus()).isSameAs(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomSslInfoConfiguration { + + @Bean + SslHealthIndicator sslHealthIndicator(SslInfo sslInfo) { + return new CustomSslHealthIndicator(sslInfo); + } + + @Bean + SslInfo customSslInfo(SslBundles sslBundles) { + return new SslInfo(sslBundles, Duration.ofDays(7)); + } + + static class CustomSslHealthIndicator extends SslHealthIndicator { + + CustomSslHealthIndicator(SslInfo sslInfo) { + super(sslInfo); + } + + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/SslInfoContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/SslInfoContributor.java new file mode 100644 index 000000000000..0910d11cdcde --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/SslInfoContributor.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.info; + +import org.springframework.aot.hint.BindingReflectionHintsRegistrar; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.boot.actuate.info.Info.Builder; +import org.springframework.boot.actuate.info.SslInfoContributor.SslInfoContributorRuntimeHints; +import org.springframework.boot.info.SslInfo; +import org.springframework.context.annotation.ImportRuntimeHints; + +/** + * An {@link InfoContributor} that exposes {@link SslInfo}. + * + * @author Jonatan Ivanov + * @since 3.4.0 + */ +@ImportRuntimeHints(SslInfoContributorRuntimeHints.class) +public class SslInfoContributor implements InfoContributor { + + private final SslInfo sslInfo; + + public SslInfoContributor(SslInfo sslInfo) { + this.sslInfo = sslInfo; + } + + @Override + public void contribute(Builder builder) { + builder.withDetail("ssl", this.sslInfo); + } + + static class SslInfoContributorRuntimeHints implements RuntimeHintsRegistrar { + + private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar(); + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + this.bindingRegistrar.registerReflectionHints(hints.reflection(), SslInfo.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/SslHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/SslHealthIndicator.java new file mode 100644 index 000000000000..2a01cef1aac6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/SslHealthIndicator.java @@ -0,0 +1,88 @@ +/* + * 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.ssl; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.boot.actuate.health.AbstractHealthIndicator; +import org.springframework.boot.actuate.health.Health.Builder; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.info.SslInfo.CertificateChain; +import org.springframework.boot.info.SslInfo.CertificateInfo.Validity; + +/** + * {@link HealthIndicator} that checks the certificates the application uses and reports + * {@link Status#OUT_OF_SERVICE} when a certificate is invalid or "WILL_EXPIRE_SOON" if it + * will expire within the configurable threshold. + * + * @author Jonatan Ivanov + * @since 3.4.0 + */ +public class SslHealthIndicator extends AbstractHealthIndicator { + + private final SslInfo sslInfo; + + public SslHealthIndicator(SslInfo sslInfo) { + this.sslInfo = sslInfo; + } + + @Override + protected void doHealthCheck(Builder builder) throws Exception { + List notValidCertificateChains = this.sslInfo.getBundles() + .stream() + .flatMap((bundle) -> bundle.getCertificateChains().stream()) + .filter(this::containsNotValidCertificate) + .toList(); + + if (notValidCertificateChains.isEmpty()) { + builder.status(Status.UP); + } + else { + Set statuses = collectCertificateStatuses(notValidCertificateChains); + if (statuses.contains(Validity.Status.EXPIRED) || statuses.contains(Validity.Status.NOT_YET_VALID)) { + builder.status(Status.OUT_OF_SERVICE); + } + else if (statuses.contains(Validity.Status.WILL_EXPIRE_SOON)) { + builder.status(Status.UP); + } + else { + builder.status(Status.OUT_OF_SERVICE); + } + builder.withDetail("certificateChains", notValidCertificateChains); + } + } + + private boolean containsNotValidCertificate(CertificateChain certificateChain) { + return certificateChain.getCertificates() + .stream() + .filter((certificate) -> certificate.getValidity() != null) + .anyMatch((certificate) -> certificate.getValidity().getStatus() != Validity.Status.VALID); + } + + private Set collectCertificateStatuses(List certificateChains) { + return certificateChains.stream() + .flatMap((certificateChain) -> certificateChain.getCertificates().stream()) + .filter((certificate) -> certificate.getValidity() != null) + .map((certificate) -> certificate.getValidity().getStatus()) + .collect(Collectors.toUnmodifiableSet()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/package-info.java new file mode 100644 index 000000000000..a4296abf5e29 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Actuator support for ssl concerns. + */ +package org.springframework.boot.actuate.ssl; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/SslInfoContributorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/SslInfoContributorTests.java new file mode 100644 index 000000000000..adfff88f00bd --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/SslInfoContributorTests.java @@ -0,0 +1,63 @@ +/* + * 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.info; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.boot.actuate.info.SslInfoContributor.SslInfoContributorRuntimeHints; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SslInfoContributor}. + * + * @author Jonatan Ivanov + */ +class SslInfoContributorTests { + + @Test + void sslInfoShouldBeAdded() { + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mock(SslBundle.class)); + SslInfo sslInfo = new SslInfo(sslBundles, Duration.ofDays(14)); + SslInfoContributor sslInfoContributor = new SslInfoContributor(sslInfo); + Info.Builder builder = new Info.Builder(); + sslInfoContributor.contribute(builder); + Info info = builder.build(); + assertThat(info.getDetails().get("ssl")).isInstanceOf(SslInfo.class); + } + + @Test + void shouldRegisterHints() { + RuntimeHints runtimeHints = new RuntimeHints(); + new SslInfoContributorRuntimeHints().registerHints(runtimeHints, getClass().getClassLoader()); + assertThat(RuntimeHintsPredicates.reflection() + .onType(SslInfo.class) + .withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS)) + .accepts(runtimeHints); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/ssl/SslHealthIndicatorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/ssl/SslHealthIndicatorTests.java new file mode 100644 index 000000000000..d76fe3c41179 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/ssl/SslHealthIndicatorTests.java @@ -0,0 +1,124 @@ +/* + * 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.ssl; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.info.SslInfo; +import org.springframework.boot.info.SslInfo.Bundle; +import org.springframework.boot.info.SslInfo.CertificateChain; +import org.springframework.boot.info.SslInfo.CertificateInfo; +import org.springframework.boot.info.SslInfo.CertificateInfo.Validity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SslHealthIndicator}. + * + * @author Jonatan Ivanov + */ +class SslHealthIndicatorTests { + + private HealthIndicator healthIndicator; + + private Validity validity; + + @BeforeEach + void setUp() { + SslInfo sslInfo = mock(SslInfo.class); + Bundle bundle = mock(Bundle.class); + CertificateChain certificateChain = mock(CertificateChain.class); + CertificateInfo certificateInfo = mock(CertificateInfo.class); + + this.healthIndicator = new SslHealthIndicator(sslInfo); + this.validity = mock(Validity.class); + + given(sslInfo.getBundles()).willReturn(List.of(bundle)); + given(bundle.getCertificateChains()).willReturn(List.of(certificateChain)); + given(certificateChain.getCertificates()).willReturn(List.of(certificateInfo)); + given(certificateInfo.getValidity()).willReturn(this.validity); + } + + @Test + void shouldBeUpIfNoSslIssuesDetected() { + given(this.validity.getStatus()).willReturn(Validity.Status.VALID); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + void shouldBeOutOfServiceIfACertificateIsExpired() { + given(this.validity.getStatus()).willReturn(Validity.Status.EXPIRED); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + } + + @Test + @SuppressWarnings("unchecked") + void shouldBeOutOfServiceIfACertificateIsNotYetValid() { + given(this.validity.getStatus()).willReturn(Validity.Status.NOT_YET_VALID); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + } + + @Test + @SuppressWarnings("unchecked") + void shouldReportWarningIfACertificateWillExpireSoon() { + given(this.validity.getStatus()).willReturn(Validity.Status.WILL_EXPIRE_SOON); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + } + + @Test + @SuppressWarnings("unchecked") + void shouldBeOutOfServiceIfACertificateHasUnMappedValidityStatus() { + given(this.validity.getStatus()).willReturn(mock(Validity.Status.class)); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.OUT_OF_SERVICE); + assertThat(health.getDetails()).hasSize(1); + List certificateChains = (List) health.getDetails() + .get("certificateChains"); + assertThat(certificateChains).hasSize(1); + assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc index 29f879dda1f7..dc9116d953b8 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc @@ -648,10 +648,18 @@ with the `key` listed in the following table: | `redis` | javadoc:org.springframework.boot.actuate.data.redis.RedisHealthIndicator[] | Checks that a Redis server is up. + +| `ssl` +| javadoc:org.springframework.boot.actuate.ssl.SslHealthIndicator[] +| Checks that SSL Cerificates are ok. |=== TIP: You can disable them all by setting the configprop:management.health.defaults.enabled[] property. +TIP: The `ssl` `HealthIndicator` has a "warning threshold" property. If an SSL Certificate will be invalid within the time span defined by this threshold, the `HealthIndicator` will warn you but it will still return HTTP 200 to not disrupt the application. You can use this threshold to give yourself enough lead time to rotate the soon to be expired certificate. See the `management.health.ssl.certificate-validity-warning-threshold` property. + + + Additional `HealthIndicators` are available but are not enabled by default: [cols="3,4,6"] @@ -1110,12 +1118,17 @@ When appropriate, Spring auto-configures the following `InfoContributor` beans: | Exposes process information. | None. +| `ssl` +| javadoc:org.springframework.boot.actuate.info.SslInfoContributor[] +| Exposes SSL Certificate information. +| An xref:features/ssl.adoc#features.ssl.bundles[SSL Bundle] configured. + |=== Whether an individual contributor is enabled is controlled by its `management.info..enabled` property. Different contributors have different defaults for this property, depending on their prerequisites and the nature of the information that they expose. -With no prerequisites to indicate that they should be enabled, the `env`, `java`, `os`, and `process` contributors are disabled by default. +With no prerequisites to indicate that they should be enabled, the `env`, `java`, `os`, and `process` contributors are disabled by default. The `ssl` contributor has a prerequisite of having an xref:features/ssl.adoc#features.ssl.bundles[SSL Bundle] configured but it is disabled by default. Each can be enabled by setting its `management.info..enabled` property to `true`. The `build` and `git` info contributors are enabled by default. @@ -1225,6 +1238,13 @@ The `info` endpoint publishes information about your process, see javadoc:org.sp +[[actuator.endpoints.info.ssl-information]] +=== SSL Information + +The `info` endpoint publishes information about your SSL Certificates (that are configured through xref:features/ssl.adoc#features.ssl.bundles[SSL Bundles]), see javadoc:org.springframework.boot.info.SslInfo[] for more details. This endpoint reuses the "warning threshold" property of javadoc:org.springframework.boot.actuate.ssl.SslHealthIndicator[]: if an SSL Certificate will be invalid within the time span defined by this threshold, it will trigger a warning. See the `management.health.ssl.certificate-validity-warning-threshold` property. + + + [[actuator.endpoints.info.writing-custom-info-contributors]] === Writing Custom InfoContributors 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 new file mode 100644 index 000000000000..c233ce704725 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java @@ -0,0 +1,243 @@ +/* + * 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.info; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.Certificate; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; + +/** + * Information about the certificates that the application uses. + * + * @author Jonatan Ivanov + * @since 3.4.0 + */ +public class SslInfo { + + private final SslBundles sslBundles; + + private final Duration certificateValidityWarningThreshold; + + public SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold) { + this.sslBundles = sslBundles; + this.certificateValidityWarningThreshold = certificateValidityWarningThreshold; + } + + public List getBundles() { + return this.sslBundles.getBundles() + .entrySet() + .stream() + .map((entry) -> new Bundle(entry.getKey(), entry.getValue())) + .toList(); + } + + public final class Bundle { + + private final String name; + + private final List certificateChains; + + private Bundle(String name, SslBundle sslBundle) { + this.name = name; + this.certificateChains = createCertificateChains(sslBundle.getStores().getKeyStore()); + } + + public String getName() { + return this.name; + } + + public List getCertificateChains() { + return this.certificateChains; + } + + private List createCertificateChains(KeyStore keyStore) { + try { + return Collections.list(keyStore.aliases()) + .stream() + .map((alias) -> new CertificateChain(alias, getCertificates(alias, keyStore))) + .toList(); + } + catch (KeyStoreException ex) { + return Collections.emptyList(); + } + } + + private List getCertificates(String alias, KeyStore keyStore) { + try { + Certificate[] certificateChain = keyStore.getCertificateChain(alias); + return (certificateChain != null) ? List.of(certificateChain) : Collections.emptyList(); + } + catch (KeyStoreException ex) { + return Collections.emptyList(); + } + } + + } + + public final class CertificateChain { + + private final String alias; + + private final List certificates; + + CertificateChain(String alias, List certificates) { + this.alias = alias; + this.certificates = certificates.stream().map(CertificateInfo::new).toList(); + } + + public String getAlias() { + return this.alias; + } + + public List getCertificates() { + return this.certificates; + } + + } + + public final class CertificateInfo { + + private final X509Certificate certificate; + + private CertificateInfo(Certificate certificate) { + if (certificate instanceof X509Certificate x509Certificate) { + this.certificate = x509Certificate; + } + else { + this.certificate = null; + } + } + + public String getSubject() { + return (this.certificate != null) ? this.certificate.getSubjectX500Principal().getName() : null; + } + + public String getIssuer() { + return (this.certificate != null) ? this.certificate.getIssuerX500Principal().getName() : null; + } + + public String getSerialNumber() { + return (this.certificate != null) ? this.certificate.getSerialNumber().toString(16) : null; + } + + public String getVersion() { + return (this.certificate != null) ? "V" + this.certificate.getVersion() : null; + } + + public String getSignatureAlgorithmName() { + return (this.certificate != null) ? this.certificate.getSigAlgName() : null; + } + + public Instant getValidityStarts() { + return (this.certificate != null) ? this.certificate.getNotBefore().toInstant() : null; + } + + public Instant getValidityEnds() { + return (this.certificate != null) ? this.certificate.getNotAfter().toInstant() : null; + } + + public Validity getValidity() { + try { + if (this.certificate != null) { + this.certificate.checkValidity(); + if (isCloseToBeExpired(this.certificate, SslInfo.this.certificateValidityWarningThreshold)) { + return new Validity(Status.WILL_EXPIRE_SOON, + "Certificate will expire within threshold (%s) at %s".formatted( + SslInfo.this.certificateValidityWarningThreshold, this.getValidityEnds())); + } + else { + return new Validity(Status.VALID, null); + } + } + else { + return null; + } + } + catch (CertificateNotYetValidException exception) { + return new Validity(Status.NOT_YET_VALID, "Not valid before %s".formatted(this.getValidityStarts())); + } + catch (CertificateExpiredException exception) { + return new Validity(Status.EXPIRED, "Not valid after %s".formatted(this.getValidityEnds())); + } + } + + private boolean isCloseToBeExpired(X509Certificate certificate, Duration certificateValidityThreshold) { + Instant shouldBeValidAt = Instant.now().plus(certificateValidityThreshold); + Instant expiresAt = certificate.getNotAfter().toInstant(); + return shouldBeValidAt.isAfter(expiresAt); + } + + public static class Validity { + + private final Status status; + + private final String message; + + Validity(Status status, String message) { + this.status = status; + this.message = message; + } + + public Status getStatus() { + return this.status; + } + + public String getMessage() { + return this.message; + } + + public enum Status { + + /** + * The certificate is valid. + */ + VALID, + + /** + * The certificate's validity date range is in the future. + */ + NOT_YET_VALID, + + /** + * The certificate's validity date range is in the past. + */ + EXPIRED, + + /** + * The certificate is still valid but the end of its validity date range + * is within the defined threshold. + */ + WILL_EXPIRE_SOON + + } + + } + + } + +} 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 8c999e5ccf4f..8cef5d4e652f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * 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. @@ -18,9 +18,11 @@ import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -34,6 +36,7 @@ * @author Scott Frederick * @author Moritz Halbritter * @author Phillip Webb + * @author Jonatan Ivanov * @since 3.1.0 */ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { @@ -67,6 +70,13 @@ public SslBundle getBundle(String name) { return getRegistered(name).getBundle(); } + @Override + public Map getBundles() { + return this.registeredBundles.entrySet() + .stream() + .collect(Collectors.toUnmodifiableMap(Entry::getKey, (entry) -> entry.getValue().getBundle())); + } + @Override public void addBundleUpdateHandler(String name, Consumer updateHandler) throws NoSuchSslBundleException { getRegistered(name).addUpdateHandler(updateHandler); 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 21afc4346a61..d6c527066221 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * 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. @@ -16,6 +16,7 @@ package org.springframework.boot.ssl; +import java.util.Map; import java.util.function.Consumer; /** @@ -23,6 +24,7 @@ * * @author Scott Frederick * @author Moritz Halbritter + * @author Jonatan Ivanov * @since 3.1.0 */ public interface SslBundles { @@ -35,6 +37,13 @@ public interface SslBundles { */ SslBundle getBundle(String name) throws NoSuchSslBundleException; + /** + * Return all the {@link SslBundle SslBundles} by name. + * @return the bundles + * @since 3.4.0 + */ + Map getBundles(); + /** * Add a handler that will be called each time the named bundle is updated. * @param name the bundle name diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java new file mode 100644 index 000000000000..48646091cda3 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java @@ -0,0 +1,268 @@ +/* + * 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.info; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.info.SslInfo.Bundle; +import org.springframework.boot.info.SslInfo.CertificateChain; +import org.springframework.boot.info.SslInfo.CertificateInfo; +import org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +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 SslInfo}. + * + * @author Jonatan Ivanov + */ +class SslInfoTests { + + @Test + void validCertificatesShouldProvideSslInfo() { + SslInfo sslInfo = createSslInfo("classpath:test.p12"); + assertThat(sslInfo.getBundles()).hasSize(1); + Bundle bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-0"); + assertThat(bundle.getCertificateChains()).hasSize(4); + assertThat(bundle.getCertificateChains().get(0).getAlias()).isEqualTo("spring-boot"); + assertThat(bundle.getCertificateChains().get(0).getCertificates()).hasSize(1); + assertThat(bundle.getCertificateChains().get(1).getAlias()).isEqualTo("test-alias"); + assertThat(bundle.getCertificateChains().get(1).getCertificates()).hasSize(1); + assertThat(bundle.getCertificateChains().get(2).getAlias()).isEqualTo("spring-boot-cert"); + assertThat(bundle.getCertificateChains().get(2).getCertificates()).isEmpty(); + assertThat(bundle.getCertificateChains().get(3).getAlias()).isEqualTo("test-alias-cert"); + assertThat(bundle.getCertificateChains().get(3).getCertificates()).isEmpty(); + + CertificateInfo cert1 = bundle.getCertificateChains().get(0).getCertificates().get(0); + assertThat(cert1.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); + assertThat(cert1.getIssuer()).isEqualTo(cert1.getSubject()); + assertThat(cert1.getSerialNumber()).isNotEmpty(); + assertThat(cert1.getVersion()).isEqualTo("V3"); + assertThat(cert1.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); + assertThat(cert1.getValidityStarts()).isInThePast(); + assertThat(cert1.getValidityEnds()).isInTheFuture(); + assertThat(cert1.getValidity()).isNotNull(); + assertThat(cert1.getValidity().getStatus()).isSameAs(Status.VALID); + assertThat(cert1.getValidity().getMessage()).isNull(); + + CertificateInfo cert2 = bundle.getCertificateChains().get(1).getCertificates().get(0); + assertThat(cert2.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); + assertThat(cert2.getIssuer()).isEqualTo(cert2.getSubject()); + assertThat(cert2.getSerialNumber()).isNotEmpty(); + assertThat(cert2.getVersion()).isEqualTo("V3"); + assertThat(cert2.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); + assertThat(cert2.getValidityStarts()).isInThePast(); + assertThat(cert2.getValidityEnds()).isInTheFuture(); + assertThat(cert2.getValidity()).isNotNull(); + assertThat(cert2.getValidity().getStatus()).isSameAs(Status.VALID); + assertThat(cert2.getValidity().getMessage()).isNull(); + } + + @Test + void notYetValidCertificateShouldProvideSslInfo() { + SslInfo sslInfo = createSslInfo("classpath:test-not-yet-valid.p12"); + assertThat(sslInfo.getBundles()).hasSize(1); + Bundle bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-0"); + assertThat(bundle.getCertificateChains()).hasSize(1); + CertificateChain certificateChain = bundle.getCertificateChains().get(0); + assertThat(certificateChain.getAlias()).isEqualTo("spring-boot"); + List certs = certificateChain.getCertificates(); + assertThat(certs).hasSize(1); + CertificateInfo cert = certs.get(0); + assertThat(cert.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); + assertThat(cert.getIssuer()).isEqualTo(cert.getSubject()); + assertThat(cert.getSerialNumber()).isNotEmpty(); + assertThat(cert.getVersion()).isEqualTo("V3"); + assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); + assertThat(cert.getValidityStarts()).isInTheFuture(); + assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID); + assertThat(cert.getValidity().getMessage()).startsWith("Not valid before"); + } + + @Test + void expiredCertificateShouldProvideSslInfo() { + SslInfo sslInfo = createSslInfo("classpath:test-expired.p12"); + assertThat(sslInfo.getBundles()).hasSize(1); + Bundle bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-0"); + assertThat(bundle.getCertificateChains()).hasSize(1); + CertificateChain certificateChain = bundle.getCertificateChains().get(0); + assertThat(certificateChain.getAlias()).isEqualTo("spring-boot"); + List certs = certificateChain.getCertificates(); + assertThat(certs).hasSize(1); + CertificateInfo cert = certs.get(0); + assertThat(cert.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); + assertThat(cert.getIssuer()).isEqualTo(cert.getSubject()); + assertThat(cert.getSerialNumber()).isNotEmpty(); + assertThat(cert.getVersion()).isEqualTo("V3"); + assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); + assertThat(cert.getValidityStarts()).isInThePast(); + assertThat(cert.getValidityEnds()).isInThePast(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED); + assertThat(cert.getValidity().getMessage()).startsWith("Not valid after"); + } + + @Test + void soonToBeExpiredCertificateShouldProvideSslInfo(@TempDir Path tempDir) + throws IOException, InterruptedException { + Path keyStore = createKeyStore(tempDir); + SslInfo sslInfo = createSslInfo(keyStore.toString()); + assertThat(sslInfo.getBundles()).hasSize(1); + Bundle bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-0"); + assertThat(bundle.getCertificateChains()).hasSize(1); + CertificateChain certificateChain = bundle.getCertificateChains().get(0); + assertThat(certificateChain.getAlias()).isEqualTo("spring-boot"); + List certs = certificateChain.getCertificates(); + assertThat(certs).hasSize(1); + CertificateInfo cert = certs.get(0); + assertThat(cert.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); + assertThat(cert.getIssuer()).isEqualTo(cert.getSubject()); + assertThat(cert.getSerialNumber()).isNotEmpty(); + assertThat(cert.getVersion()).isEqualTo("V3"); + assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); + assertThat(cert.getValidityStarts()).isInThePast(); + assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.WILL_EXPIRE_SOON); + assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold"); + } + + @Test + void multipleBundlesShouldProvideSslInfo(@TempDir Path tempDir) throws IOException, InterruptedException { + Path keyStore = createKeyStore(tempDir); + SslInfo sslInfo = createSslInfo("classpath:test.p12", "classpath:test-not-yet-valid.p12", + "classpath:test-expired.p12", keyStore.toString()); + assertThat(sslInfo.getBundles()).hasSize(4); + assertThat(sslInfo.getBundles()).allSatisfy((bundle) -> assertThat(bundle.getName()).startsWith("test-")); + + List certs = sslInfo.getBundles() + .stream() + .flatMap((bundle) -> bundle.getCertificateChains().stream()) + .flatMap((certificateChain) -> certificateChain.getCertificates().stream()) + .toList(); + + assertThat(certs).hasSize(5); + assertThat(certs).allSatisfy((cert) -> { + assertThat(cert.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); + assertThat(cert.getIssuer()).isEqualTo(cert.getSubject()); + assertThat(cert.getSerialNumber()).isNotEmpty(); + assertThat(cert.getVersion()).isEqualTo("V3"); + assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); + assertThat(cert.getValidity()).isNotNull(); + }); + + assertThat(certs).anySatisfy((cert) -> { + assertThat(cert.getValidityStarts()).isInThePast(); + assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.VALID); + assertThat(cert.getValidity().getMessage()).isNull(); + }); + + assertThat(certs).satisfiesOnlyOnce((cert) -> { + assertThat(cert.getValidityStarts()).isInTheFuture(); + assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID); + assertThat(cert.getValidity().getMessage()).startsWith("Not valid before"); + }); + + assertThat(certs).satisfiesOnlyOnce((cert) -> { + assertThat(cert.getValidityStarts()).isInThePast(); + assertThat(cert.getValidityEnds()).isInThePast(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED); + assertThat(cert.getValidity().getMessage()).startsWith("Not valid after"); + }); + + assertThat(certs).satisfiesOnlyOnce((cert) -> { + assertThat(cert.getValidityStarts()).isInThePast(); + assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidity()).isNotNull(); + assertThat(cert.getValidity().getStatus()).isSameAs(Status.WILL_EXPIRE_SOON); + assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold"); + }); + } + + private SslInfo createSslInfo(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 new SslInfo(sslBundleRegistry, Duration.ofDays(7)); + } + + private Path createKeyStore(Path directory) throws IOException, InterruptedException { + Path keyStore = directory.resolve("test.p12"); + Process process = createProcessBuilder(keyStore).start(); + int exitCode = process.waitFor(); + if (exitCode != 0) { + String out = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) + .lines() + .collect(Collectors.joining("\n")); + throw new RuntimeException("Unexpected exit code from keytool: %d\n%s".formatted(exitCode, out)); + } + + return keyStore; + } + + private ProcessBuilder createProcessBuilder(Path keystore) { + // @formatter:off + ProcessBuilder processBuilder = new ProcessBuilder( + "keytool", + "-genkeypair", + "-storetype", "PKCS12", + "-alias", "spring-boot", + "-keyalg", "RSA", + "-storepass", "secret", + "-keypass", "secret", + "-keystore", keystore.toString(), + "-dname", "CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US", + "-validity", "1", + "-ext", "SAN=DNS:localhost,IP:::1,IP:127.0.0.1" + ); + // @formatter:on + processBuilder.redirectErrorStream(true); + + return processBuilder; + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java index 110e4c79fbf3..b6f7bc655f85 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java @@ -105,6 +105,15 @@ void getBundleReturnsBundle() { assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2); } + @Test + void getBundlesReturnsBundles() { + this.registry.registerBundle("test1", this.bundle1); + this.registry.registerBundle("test2", this.bundle2); + assertThat(this.registry.getBundles()).hasSize(2) + .containsEntry("test1", this.bundle1) + .containsEntry("test2", this.bundle2); + } + @Test void updateBundleShouldNotifyUpdateHandlers() { AtomicReference updatedBundle = new AtomicReference<>(); diff --git a/spring-boot-project/spring-boot/src/test/resources/test-expired.p12 b/spring-boot-project/spring-boot/src/test/resources/test-expired.p12 new file mode 100644 index 0000000000000000000000000000000000000000..c0936e27836bbba7f43ebba207a054ac07626299 GIT binary patch literal 2802 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 literal 0 HcmV?d00001 diff --git a/spring-boot-project/spring-boot/src/test/resources/test-not-yet-valid.p12 b/spring-boot-project/spring-boot/src/test/resources/test-not-yet-valid.p12 new file mode 100644 index 0000000000000000000000000000000000000000..e3deb5eab50d2dce763bf2d2698fe65038314718 GIT binary patch literal 2802 zcma)8c{CJ?8lM?sG`6wKq_SklzRZlVXEH=t;>m=Ri|k7=*>`4!q3p}Jh)YE=c4a8C zM%$Wm$%2SP~yF5S+ zU=f+~9+=D-cMOxzWcJ4YqS(v8WcHF{_~CIEK)C+n;^qW`ipcEvV^9a}_RGP}gSJI$ z{=IUe6~OR=lXHVofa&n{g}`_4P;i*tpRK1sAQ?UoP#nz-Iq~m{ASgQkEee4;QjLJF zY+#@~n6G}Kc*!DIY^2J=^lqWU1OiM3v$!9QYI8glD!5*mwcQ!+!59?68dhOmWy09L z(=wDx?Uq}U^&-;S8Scnjr6Enb51XY@at<(FeyG~ni~f%^_IejlEpbmvuR0{03%s*= z#0PLQ2Q0*1jk+<|ibq`;@#e2?cr+7k8E^fN!9OZ+tJszcikHHCDO$A0wxXs>n9@r;!Dba?I;needQHER>GCe~QNr`8_Un{EXPYB(8=pLT6 zTGS75vu}U~k1{3N=fcy#!=tu05@MNEK{IXqjcQu>Cd%*&vo!bW7k|Xi3Y2s*)Vp<_ zcv)#t{Nid2_=f##_-mE0Wm!BzIky8X_R*iK%3<1qY}l5bHz=vomrW3}Ct1AIT!!4X ze`G?#Ut+>;LUL_fvy&Nh;NqWcQ6mJ`)mYlKH7`Q-!<;Z8VZLT^Qgg-O~7$WoI5(-`OUt>NkFNu-*RefF%^o!bV)7)JYH_v4sYMdZpgFq zmJ|-UDeFv|`K}uUL?k(e#2d?wX_!;_dbzvt5el4twT3YtV>5eGxO;kvC-aP?^w|1C z#`fBrnopec#6>1hb7UGjFYH#H^?CkIzf3N^5&PKy>_b|9GfQPWwPeU_2KR&zgRs2= zCi)gKx5IdXwGK|X5DgE-KPC;cg7f%4Pb3Bwu`2GX4HE0h5R~LOT(7DsZ2CxcKsE3v z`0RiWtxc3K<*7?JoU4S@R5IgaITCb!)-3tvVaGkF7+bUgWz@~9Up3qHER-Bz;hEFt z9*0!5SMb`fkT>hfIGPufb~(*Z9JE$WnYbSiddG3X99U^5En-^gSkCJ$y`_C7@%AvD zhq<#!(wWS|jk^vtIJB;P0n+#0fP5vWYahf{FV2kQPqis&#)jst)ouyuPth;w=?leZ zg@NR~91oD&X0Ek&XLLfXd)z+NE>#GR7X+BLqy)Ez*?&}(SWXL0KVzVMkN!G@3xg2X z3DM2K=!nrtUJNE{`#Fv?4CZitPv7l_gJ`ZvO~xKDljw=W^1nOl;A4s^HfZ|Y9*0~_ zOS!~Uem^O?f&hb@o&-BzgJLL+7qr$$6(Ohv)a^@~0?9;#r<4(Jt@ zfAcRD9q`F|S4l{tzDr<wgIG2=}_L1x~KE5mX+D(hD9ng_{uR~nF9Dkz3L ztY*FN9aTTGFC5zWEKPm+ToKiU>+#g(Zh}^-U)gE@7ebDQ`!+A6%9kV`6EprwArB@~W1C3X_v4c;*h#n2oduC!)}{pLOw5(UfEY3u4c1jy&DhXgv}JR}6*Sf;OCGlGw#HCk zip_7d@rzV)165-$7qOSBSQcH~B_Mutx15gWy-HmY9%~5rg!A+(O&(x{LwI`Z=moaH z2IZkkdKOdY(4r7)rre`G6ks-hcV@WqGV2e*sr?|Ee9S+h+#gJ2{T%SqwFMeB<$DBS z8kkN+YCAKQ{l8lpcn7dzgmcBnhJ(BQQ~pcABy`CN#lew$(sh|7t{i}pj>?Yh>W;=h z*}{siR~8RdLmEsR4G&wQ+bqa69Twk46Fvizsswptm+&4-#>^y10k7gM%gzJnV5(=0 zYiW_p@qhL5$iHjuYU{pT8v zj?6oZ?@CG;u+<9)XIGlT-}9CJs&L;8h@IEpD!q({pZ)$2CFY!&K)pkC+j|a@dR@wIF{d+=w zv+a;l?|X#c1x*&>+|9BknjR^4iF%S`JBR}ORtrHy+KARSirwjUcrl4Dz*S~h`cNX+QFPa2Kz{lA$Hua>WDR(%Eju|YA)-&Gxt7;{zS zHOkX`&{3wCypZ9Ud z7GOUUyuzPMDb(4{Ksn=1I1LI^mQ>n}SnA*wVqOpQr|7@?t~Ew^zS4er2S{Y5>EXo! z#R=EYW@rqW=hq(y0 entity = this.restTemplate.getForEntity("/actuator/info", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonContent body = new JsonContent(entity.getBody()); + assertThat(body).extractingPath("ssl.bundles[0].name").isEqualTo("ssldemo"); + assertThat(body).extractingPath("ssl.bundles[0].certificateChains[0].alias") + .isEqualTo("spring-boot-ssl-sample"); + assertThat(body).extractingPath("ssl.bundles[0].certificateChains[0].certificates[0].issuer") + .isEqualTo("CN=localhost,OU=Unknown,O=Unknown,L=Unknown,ST=Unknown,C=Unknown"); + assertThat(body).extractingPath("ssl.bundles[0].certificateChains[0].certificates[0].subject") + .isEqualTo("CN=localhost,OU=Unknown,O=Unknown,L=Unknown,ST=Unknown,C=Unknown"); + assertThat(body).extractingPath("ssl.bundles[0].certificateChains[0].certificates[0].validity.status") + .isEqualTo("EXPIRED"); + assertThat(body).extractingPath("ssl.bundles[0].certificateChains[0].certificates[0].validity.message") + .asString() + .startsWith("Not valid after "); + } + + @Test + void testSslHealth() { + ResponseEntity entity = this.restTemplate.getForEntity("/actuator/health", String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + JsonContent body = new JsonContent(entity.getBody()); + assertThat(body).extractingPath("status").isEqualTo("OUT_OF_SERVICE"); + assertThat(body).extractingPath("components.ssl.status").isEqualTo("OUT_OF_SERVICE"); + assertThat(body).extractingPath("components.ssl.details.certificateChains[0].alias") + .isEqualTo("spring-boot-ssl-sample"); + assertThat(body).extractingPath("components.ssl.details.certificateChains[0].certificates[0].issuer") + .isEqualTo("CN=localhost,OU=Unknown,O=Unknown,L=Unknown,ST=Unknown,C=Unknown"); + assertThat(body).extractingPath("components.ssl.details.certificateChains[0].certificates[0].subject") + .isEqualTo("CN=localhost,OU=Unknown,O=Unknown,L=Unknown,ST=Unknown,C=Unknown"); + assertThat(body).extractingPath("components.ssl.details.certificateChains[0].certificates[0].validity.status") + .isEqualTo("EXPIRED"); + assertThat(body).extractingPath("components.ssl.details.certificateChains[0].certificates[0].validity.message") + .asString() + .startsWith("Not valid after "); + } + }