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 b26455ad43..08b002a330 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.InfoContributor; 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.autoconfigure.info.ProjectInfoAutoConfiguration; 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 @@ import org.springframework.core.env.ConfigurableEnvironment; * @since 2.0.0 */ @AutoConfiguration(after = ProjectInfoAutoConfiguration.class) -@EnableConfigurationProperties(InfoContributorProperties.class) +@EnableConfigurationProperties({ InfoContributorProperties.class, SslHealthIndicatorProperties.class }) public class InfoContributorAutoConfiguration { /** @@ -100,4 +104,18 @@ public class InfoContributorAutoConfiguration { return new ProcessInfoContributor(); } + @Bean + @ConditionalOnEnabledInfoContributor(value = "ssl", fallback = InfoContributorFallback.DISABLE) + @Order(DEFAULT_ORDER) + SslInfoContributor sslInfoContributor(SslInfo sslInfo) { + return new SslInfoContributor(sslInfo); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledInfoContributor(value = "ssl", fallback = InfoContributorFallback.DISABLE) + 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 0000000000..b8f9f95f43 --- /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") + SslHealthIndicator sslHealthIndicator(SslInfo sslInfo) { + return new SslHealthIndicator(sslInfo); + } + + @Bean + @ConditionalOnMissingBean + 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 0000000000..eb897d70eb --- /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 0000000000..bfeaa736f6 --- /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 15f80b7928..d507ce390f 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,12 @@ "description": "Whether to enable Redis health check.", "defaultValue": true }, + { + "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 +289,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 2a382e63bb..5bcd3472fb 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 c9e5d73b5b..dc276ab5c6 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.InfoContributor; 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 @@ class InfoContributorAutoConfigurationTests { @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 @@ class InfoContributorAutoConfigurationTests { }); } + @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 @@ class InfoContributorAutoConfigurationTests { } + @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 0000000000..4895cb3e1b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/ssl/SslHealthContributorAutoConfigurationTests.java @@ -0,0 +1,142 @@ +/* + * 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}. + * + * @author Jonatan Ivanov + */ +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 + 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); + assertDetailsKeys(health); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).hasSize(1); + assertThat(invalidChains).first().isInstanceOf(CertificateChain.class); + + }); + } + + @Test + 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); + assertDetailsKeys(health); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).hasSize(1); + assertThat(invalidChains).first().isInstanceOf(CertificateChain.class); + }); + } + + @Test + 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); + assertDetailsKeys(health); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).hasSize(1); + assertThat(invalidChains).first().isInstanceOf(CertificateChain.class); + }); + } + + private static void assertDetailsKeys(Health health) { + assertThat(health.getDetails()).containsOnlyKeys("validChains", "invalidChains"); + } + + @SuppressWarnings("unchecked") + private static List getInvalidChains(Health health) { + return (List) health.getDetails().get("invalidChains"); + } + + @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 0000000000..0910d11cdc --- /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 0000000000..17eaf153d9 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/ssl/SslHealthIndicator.java @@ -0,0 +1,79 @@ +/* + * 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.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; + +/** + * {@link HealthIndicator} that checks the certificates the application uses and reports + * {@link Status#OUT_OF_SERVICE} when a certificate is invalid. + * + * @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 certificateChains = this.sslInfo.getBundles() + .stream() + .flatMap((bundle) -> bundle.getCertificateChains().stream()) + .toList(); + List validCertificateChains = certificateChains.stream() + .filter(this::containsOnlyValidCertificates) + .toList(); + List invalidCertificateChains = certificateChains.stream() + .filter(this::containsInvalidCertificate) + .toList(); + builder.withDetail("validChains", validCertificateChains); + builder.withDetail("invalidChains", invalidCertificateChains); + if (invalidCertificateChains.isEmpty()) { + builder.status(Status.UP); + } + else { + builder.status(Status.OUT_OF_SERVICE); + } + } + + private boolean containsOnlyValidCertificates(CertificateChain certificateChain) { + return certificateChain.getCertificates() + .stream() + .filter((certificate) -> certificate.getValidity() != null) + .allMatch((certificate) -> certificate.getValidity().getStatus().isValid()); + } + + private boolean containsInvalidCertificate(CertificateChain certificateChain) { + return certificateChain.getCertificates() + .stream() + .filter((certificate) -> certificate.getValidity() != null) + .anyMatch((certificate) -> !certificate.getValidity().getStatus().isValid()); + } + +} 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 0000000000..a4296abf5e --- /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 0000000000..adfff88f00 --- /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 0000000000..2318eaa299 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/ssl/SslHealthIndicatorTests.java @@ -0,0 +1,131 @@ +/* + * 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); + assertDetailsKeys(health); + List validChains = getValidChains(health); + assertThat(validChains).hasSize(1); + assertThat(validChains.get(0)).isInstanceOf(CertificateChain.class); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).isEmpty(); + } + + @Test + void shouldBeOutOfServiceIfACertificateIsExpired() { + given(this.validity.getStatus()).willReturn(Validity.Status.EXPIRED); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.OUT_OF_SERVICE); + assertDetailsKeys(health); + List validChains = getValidChains(health); + assertThat(validChains).isEmpty(); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).hasSize(1); + assertThat(invalidChains.get(0)).isInstanceOf(CertificateChain.class); + } + + @Test + 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); + assertDetailsKeys(health); + List validChains = getValidChains(health); + assertThat(validChains).isEmpty(); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).hasSize(1); + assertThat(invalidChains.get(0)).isInstanceOf(CertificateChain.class); + + } + + @Test + void shouldReportWarningIfACertificateWillExpireSoon() { + given(this.validity.getStatus()).willReturn(Validity.Status.WILL_EXPIRE_SOON); + Health health = this.healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertDetailsKeys(health); + List validChains = getValidChains(health); + assertThat(validChains).hasSize(1); + assertThat(validChains.get(0)).isInstanceOf(CertificateChain.class); + List invalidChains = getInvalidChains(health); + assertThat(invalidChains).isEmpty(); + } + + private static void assertDetailsKeys(Health health) { + assertThat(health.getDetails()).containsOnlyKeys("validChains", "invalidChains"); + } + + @SuppressWarnings("unchecked") + private static List getInvalidChains(Health health) { + return (List) health.getDetails().get("invalidChains"); + } + + @SuppressWarnings("unchecked") + private static List getValidChains(Health health) { + return (List) health.getDetails().get("validChains"); + } + +} 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 29f879dda1..c9bd4c7d69 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,20 @@ 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 Certificates 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 named configprop:management.health.ssl.certificate-validity-warning-threshold[]. +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. + + + Additional `HealthIndicators` are available but are not enabled by default: [cols="3,4,6"] @@ -1110,12 +1120,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 +1240,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 0000000000..e0c5e533a5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java @@ -0,0 +1,253 @@ +/* + * 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(true), + + /** + * The certificate's validity date range is in the future. + */ + NOT_YET_VALID(false), + + /** + * The certificate's validity date range is in the past. + */ + EXPIRED(false), + + /** + * The certificate is still valid, but the end of its validity date range + * is within the defined threshold. + */ + WILL_EXPIRE_SOON(true); + + private final boolean valid; + + Status(boolean valid) { + this.valid = valid; + } + + public boolean isValid() { + return this.valid; + } + + } + + } + + } + +} 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 8c999e5ccf..8cef5d4e65 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 @@ package org.springframework.boot.ssl; 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 @@ import org.springframework.util.Assert; * @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 class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { 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 21afc4346a..d6c5270662 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 @@ import java.util.function.Consumer; * * @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 0000000000..8bf7fe8579 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java @@ -0,0 +1,265 @@ +/* + * 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()).isNotEmpty(); + 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()).isNotEmpty(); + 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 110e4c79fb..b6f7bc655f 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 @@ class DefaultSslBundleRegistryTests { 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 0000000000..c0936e2783 Binary files /dev/null and b/spring-boot-project/spring-boot/src/test/resources/test-expired.p12 differ 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 0000000000..e3deb5eab5 Binary files /dev/null and b/spring-boot-project/spring-boot/src/test/resources/test-not-yet-valid.p12 differ diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/build.gradle index 6f7cd28955..a2257efe5d 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/build.gradle @@ -7,6 +7,7 @@ description = "Spring Boot Tomcat SSL smoke test" dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-actuator")) testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) testImplementation("org.apache.httpcomponents.client5:httpclient5") diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/main/resources/application.properties index 37199bfd25..c9f855cfce 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/main/resources/application.properties +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/main/resources/application.properties @@ -1,4 +1,13 @@ -server.port = 8443 -server.ssl.key-store = classpath:sample.jks -server.ssl.key-store-password = secret -server.ssl.key-password = password +server.port=8443 + +management.endpoints.web.exposure.include=* +management.endpoint.health.show-details=always +management.health.ssl.certificate-validity-warning-threshold=7d +management.health.ssl.enabled=true +management.info.ssl.enabled=true + +server.ssl.bundle=ssldemo +spring.ssl.bundle.jks.ssldemo.keystore.location=classpath:sample.jks +spring.ssl.bundle.jks.ssldemo.keystore.password=secret +spring.ssl.bundle.jks.ssldemo.keystore.type=JKS +spring.ssl.bundle.jks.ssldemo.key.password=password diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/test/java/smoketest/tomcat/ssl/SampleTomcatSslApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/test/java/smoketest/tomcat/ssl/SampleTomcatSslApplicationTests.java index 6c0e844db2..c706938811 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/test/java/smoketest/tomcat/ssl/SampleTomcatSslApplicationTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-tomcat-ssl/src/test/java/smoketest/tomcat/ssl/SampleTomcatSslApplicationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 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. @@ -25,6 +25,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.test.json.JsonContent; import static org.assertj.core.api.Assertions.assertThat; @@ -49,4 +50,43 @@ class SampleTomcatSslApplicationTests { assertThat(entity.getBody()).isEqualTo("Hello, world"); } + @Test + void testSslInfo() { + ResponseEntity 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.invalidChains[0].alias") + .isEqualTo("spring-boot-ssl-sample"); + assertThat(body).extractingPath("components.ssl.details.invalidChains[0].certificates[0].issuer") + .isEqualTo("CN=localhost,OU=Unknown,O=Unknown,L=Unknown,ST=Unknown,C=Unknown"); + assertThat(body).extractingPath("components.ssl.details.invalidChains[0].certificates[0].subject") + .isEqualTo("CN=localhost,OU=Unknown,O=Unknown,L=Unknown,ST=Unknown,C=Unknown"); + assertThat(body).extractingPath("components.ssl.details.invalidChains[0].certificates[0].validity.status") + .isEqualTo("EXPIRED"); + assertThat(body).extractingPath("components.ssl.details.invalidChains[0].certificates[0].validity.message") + .asString() + .startsWith("Not valid after "); + } + }