Add SslInfoContributor and SslHealthIndicator
See gh-41205
This commit is contained in:
parent
71ca952827
commit
5e3796e814
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package org.springframework.boot.actuate.autoconfigure.info;
|
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.BuildInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.EnvironmentInfoContributor;
|
import org.springframework.boot.actuate.info.EnvironmentInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.GitInfoContributor;
|
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.JavaInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.OsInfoContributor;
|
import org.springframework.boot.actuate.info.OsInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.ProcessInfoContributor;
|
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.AutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
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.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.boot.info.BuildProperties;
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.info.GitProperties;
|
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.context.annotation.Bean;
|
||||||
import org.springframework.core.Ordered;
|
import org.springframework.core.Ordered;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
|
@ -46,7 +50,7 @@ import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
@AutoConfiguration(after = ProjectInfoAutoConfiguration.class)
|
@AutoConfiguration(after = ProjectInfoAutoConfiguration.class)
|
||||||
@EnableConfigurationProperties(InfoContributorProperties.class)
|
@EnableConfigurationProperties({ InfoContributorProperties.class, SslHealthIndicatorProperties.class })
|
||||||
public class InfoContributorAutoConfiguration {
|
public class InfoContributorAutoConfiguration {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -100,4 +104,18 @@ public class InfoContributorAutoConfiguration {
|
||||||
return new 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());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
|
@ -227,6 +227,18 @@
|
||||||
"description": "Whether to enable Redis health check.",
|
"description": "Whether to enable Redis health check.",
|
||||||
"defaultValue": true
|
"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",
|
"name": "management.httpexchanges.recording.enabled",
|
||||||
"type": "java.lang.Boolean",
|
"type": "java.lang.Boolean",
|
||||||
|
@ -283,6 +295,12 @@
|
||||||
"description": "Whether to enable process info.",
|
"description": "Whether to enable process info.",
|
||||||
"defaultValue": false
|
"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",
|
"name": "management.metrics.binders.files.enabled",
|
||||||
"type": "java.lang.Boolean",
|
"type": "java.lang.Boolean",
|
||||||
|
|
|
@ -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.security.servlet.ManagementWebSecurityAutoConfiguration
|
||||||
org.springframework.boot.actuate.autoconfigure.session.SessionsEndpointAutoConfiguration
|
org.springframework.boot.actuate.autoconfigure.session.SessionsEndpointAutoConfiguration
|
||||||
org.springframework.boot.actuate.autoconfigure.startup.StartupEndpointAutoConfiguration
|
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.system.DiskSpaceHealthContributorAutoConfiguration
|
||||||
org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration
|
org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration
|
||||||
org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration
|
org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration
|
||||||
|
|
|
@ -16,11 +16,13 @@
|
||||||
|
|
||||||
package org.springframework.boot.actuate.autoconfigure.info;
|
package org.springframework.boot.actuate.autoconfigure.info;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
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.BuildInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.EnvironmentInfoContributor;
|
import org.springframework.boot.actuate.info.EnvironmentInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.GitInfoContributor;
|
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.JavaInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.OsInfoContributor;
|
import org.springframework.boot.actuate.info.OsInfoContributor;
|
||||||
import org.springframework.boot.actuate.info.ProcessInfoContributor;
|
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.AutoConfigurations;
|
||||||
|
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
|
||||||
import org.springframework.boot.info.BuildProperties;
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.info.GitProperties;
|
import org.springframework.boot.info.GitProperties;
|
||||||
import org.springframework.boot.info.JavaInfo;
|
import org.springframework.boot.info.JavaInfo;
|
||||||
import org.springframework.boot.info.OsInfo;
|
import org.springframework.boot.info.OsInfo;
|
||||||
import org.springframework.boot.info.ProcessInfo;
|
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.boot.test.context.runner.ApplicationContextRunner;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
@ -60,7 +66,8 @@ class InfoContributorAutoConfigurationTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void defaultInfoContributorsEnabled() {
|
void defaultInfoContributorsEnabled() {
|
||||||
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(InfoContributor.class));
|
this.contextRunner.run(
|
||||||
|
(context) -> assertThat(context).doesNotHaveBean(InfoContributor.class).doesNotHaveBean(SslInfo.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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<String, Object> 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<String, Object> 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<String, Object> content = invokeContributor(context.getBean(SslInfoContributor.class));
|
||||||
|
assertThat(content).containsKey("ssl");
|
||||||
|
assertThat(content.get("ssl")).isInstanceOf(SslInfo.class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> invokeContributor(InfoContributor contributor) {
|
private Map<String, Object> invokeContributor(InfoContributor contributor) {
|
||||||
Info.Builder builder = new Info.Builder();
|
Info.Builder builder = new Info.Builder();
|
||||||
contributor.contribute(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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<CertificateChain> certificateChains = (List<CertificateChain>) 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<CertificateChain> certificateChains = (List<CertificateChain>) 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<CertificateChain> certificateChains = (List<CertificateChain>) 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<CertificateChain> notValidCertificateChains = this.sslInfo.getBundles()
|
||||||
|
.stream()
|
||||||
|
.flatMap((bundle) -> bundle.getCertificateChains().stream())
|
||||||
|
.filter(this::containsNotValidCertificate)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (notValidCertificateChains.isEmpty()) {
|
||||||
|
builder.status(Status.UP);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Set<Validity.Status> 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<Validity.Status> collectCertificateStatuses(List<CertificateChain> certificateChains) {
|
||||||
|
return certificateChains.stream()
|
||||||
|
.flatMap((certificateChain) -> certificateChain.getCertificates().stream())
|
||||||
|
.filter((certificate) -> certificate.getValidity() != null)
|
||||||
|
.map((certificate) -> certificate.getValidity().getStatus())
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<CertificateChain> certificateChains = (List<CertificateChain>) 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<CertificateChain> certificateChains = (List<CertificateChain>) 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<CertificateChain> certificateChains = (List<CertificateChain>) 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<CertificateChain> certificateChains = (List<CertificateChain>) health.getDetails()
|
||||||
|
.get("certificateChains");
|
||||||
|
assertThat(certificateChains).hasSize(1);
|
||||||
|
assertThat(certificateChains.get(0)).isInstanceOf(CertificateChain.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -648,10 +648,18 @@ with the `key` listed in the following table:
|
||||||
| `redis`
|
| `redis`
|
||||||
| javadoc:org.springframework.boot.actuate.data.redis.RedisHealthIndicator[]
|
| javadoc:org.springframework.boot.actuate.data.redis.RedisHealthIndicator[]
|
||||||
| Checks that a Redis server is up.
|
| 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: 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:
|
Additional `HealthIndicators` are available but are not enabled by default:
|
||||||
|
|
||||||
[cols="3,4,6"]
|
[cols="3,4,6"]
|
||||||
|
@ -1110,12 +1118,17 @@ When appropriate, Spring auto-configures the following `InfoContributor` beans:
|
||||||
| Exposes process information.
|
| Exposes process information.
|
||||||
| None.
|
| 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.<id>.enabled` property.
|
Whether an individual contributor is enabled is controlled by its `management.info.<id>.enabled` property.
|
||||||
Different contributors have different defaults for this property, depending on their prerequisites and the nature of the information that they expose.
|
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.<id>.enabled` property to `true`.
|
Each can be enabled by setting its `management.info.<id>.enabled` property to `true`.
|
||||||
|
|
||||||
The `build` and `git` info contributors are enabled by default.
|
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]]
|
[[actuator.endpoints.info.writing-custom-info-contributors]]
|
||||||
=== Writing Custom InfoContributors
|
=== Writing Custom InfoContributors
|
||||||
|
|
||||||
|
|
|
@ -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<Bundle> 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<CertificateChain> certificateChains;
|
||||||
|
|
||||||
|
private Bundle(String name, SslBundle sslBundle) {
|
||||||
|
this.name = name;
|
||||||
|
this.certificateChains = createCertificateChains(sslBundle.getStores().getKeyStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CertificateChain> getCertificateChains() {
|
||||||
|
return this.certificateChains;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CertificateChain> 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<Certificate> 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<CertificateInfo> certificates;
|
||||||
|
|
||||||
|
CertificateChain(String alias, List<Certificate> certificates) {
|
||||||
|
this.alias = alias;
|
||||||
|
this.certificates = certificates.stream().map(CertificateInfo::new).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAlias() {
|
||||||
|
return this.alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CertificateInfo> 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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Map.Entry;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.commons.logging.Log;
|
import org.apache.commons.logging.Log;
|
||||||
import org.apache.commons.logging.LogFactory;
|
import org.apache.commons.logging.LogFactory;
|
||||||
|
@ -34,6 +36,7 @@ import org.springframework.util.Assert;
|
||||||
* @author Scott Frederick
|
* @author Scott Frederick
|
||||||
* @author Moritz Halbritter
|
* @author Moritz Halbritter
|
||||||
* @author Phillip Webb
|
* @author Phillip Webb
|
||||||
|
* @author Jonatan Ivanov
|
||||||
* @since 3.1.0
|
* @since 3.1.0
|
||||||
*/
|
*/
|
||||||
public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {
|
public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {
|
||||||
|
@ -67,6 +70,13 @@ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {
|
||||||
return getRegistered(name).getBundle();
|
return getRegistered(name).getBundle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, SslBundle> getBundles() {
|
||||||
|
return this.registeredBundles.entrySet()
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.toUnmodifiableMap(Entry::getKey, (entry) -> entry.getValue().getBundle()));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException {
|
public void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException {
|
||||||
getRegistered(name).addUpdateHandler(updateHandler);
|
getRegistered(name).addUpdateHandler(updateHandler);
|
||||||
|
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package org.springframework.boot.ssl;
|
package org.springframework.boot.ssl;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -23,6 +24,7 @@ import java.util.function.Consumer;
|
||||||
*
|
*
|
||||||
* @author Scott Frederick
|
* @author Scott Frederick
|
||||||
* @author Moritz Halbritter
|
* @author Moritz Halbritter
|
||||||
|
* @author Jonatan Ivanov
|
||||||
* @since 3.1.0
|
* @since 3.1.0
|
||||||
*/
|
*/
|
||||||
public interface SslBundles {
|
public interface SslBundles {
|
||||||
|
@ -35,6 +37,13 @@ public interface SslBundles {
|
||||||
*/
|
*/
|
||||||
SslBundle getBundle(String name) throws NoSuchSslBundleException;
|
SslBundle getBundle(String name) throws NoSuchSslBundleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all the {@link SslBundle SslBundles} by name.
|
||||||
|
* @return the bundles
|
||||||
|
* @since 3.4.0
|
||||||
|
*/
|
||||||
|
Map<String, SslBundle> getBundles();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a handler that will be called each time the named bundle is updated.
|
* Add a handler that will be called each time the named bundle is updated.
|
||||||
* @param name the bundle name
|
* @param name the bundle name
|
||||||
|
|
|
@ -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<CertificateInfo> 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<CertificateInfo> 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<CertificateInfo> 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<CertificateInfo> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -105,6 +105,15 @@ class DefaultSslBundleRegistryTests {
|
||||||
assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2);
|
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
|
@Test
|
||||||
void updateBundleShouldNotifyUpdateHandlers() {
|
void updateBundleShouldNotifyUpdateHandlers() {
|
||||||
AtomicReference<SslBundle> updatedBundle = new AtomicReference<>();
|
AtomicReference<SslBundle> updatedBundle = new AtomicReference<>();
|
||||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -7,6 +7,7 @@ description = "Spring Boot Tomcat SSL smoke test"
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web"))
|
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(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
|
||||||
testImplementation("org.apache.httpcomponents.client5:httpclient5")
|
testImplementation("org.apache.httpcomponents.client5:httpclient5")
|
||||||
|
|
|
@ -1,4 +1,13 @@
|
||||||
server.port=8443
|
server.port=8443
|
||||||
server.ssl.key-store = classpath:sample.jks
|
|
||||||
server.ssl.key-store-password = secret
|
management.endpoints.web.exposure.include=*
|
||||||
server.ssl.key-password = password
|
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
|
||||||
|
|
|
@ -25,6 +25,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory;
|
import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.json.JsonContent;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@ -49,4 +50,43 @@ class SampleTomcatSslApplicationTests {
|
||||||
assertThat(entity.getBody()).isEqualTo("Hello, world");
|
assertThat(entity.getBody()).isEqualTo("Hello, world");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSslInfo() {
|
||||||
|
ResponseEntity<String> 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<String> 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 ");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue