Add SslInfoContributor and SslHealthIndicator

This commit is contained in:
Jonatan Ivanov 2023-11-16 15:33:57 -08:00
parent e722200876
commit 0ef93b2113
No known key found for this signature in database
GPG Key ID: 828AFD0254A84974
12 changed files with 523 additions and 6 deletions

View File

@ -16,6 +16,8 @@
package org.springframework.boot.actuate.autoconfigure.info;
import java.time.Duration;
import org.springframework.boot.actuate.info.BuildInfoContributor;
import org.springframework.boot.actuate.info.EnvironmentInfoContributor;
import org.springframework.boot.actuate.info.GitInfoContributor;
@ -23,14 +25,18 @@ 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;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
import org.springframework.boot.autoconfigure.web.ServerProperties;
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;
@ -100,4 +106,19 @@ public class InfoContributorAutoConfiguration {
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(ServerProperties serverProperties, SslBundles sslBundles) {
// TODO: Get the certificateValidityThreshold from a property
return new SslInfo(serverProperties.getSsl(), sslBundles, Duration.ofDays(7));
}
}

View File

@ -0,0 +1,57 @@
/*
* 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.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.autoconfigure.web.ServerProperties;
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")
public class SslHealthContributorAutoConfiguration {
@Bean
@ConditionalOnMissingBean(name = "sslHealthIndicator")
public SslHealthIndicator sslHealthIndicator(SslInfo sslInfo) {
return new SslHealthIndicator(sslInfo);
}
@Bean
@ConditionalOnMissingBean
public SslInfo sslInfo(ServerProperties serverProperties, SslBundles sslBundles) {
// TODO: Get the certificateValidityThreshold from a property
// TODO: This is the same as the one in InfoContributorAutoConfiguration,
// should we keep just one?
return new SslInfo(serverProperties.getSsl(), sslBundles, Duration.ofDays(7));
}
}

View File

@ -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;

View File

@ -103,6 +103,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

View File

@ -0,0 +1,41 @@
/*
* 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.boot.actuate.info.Info.Builder;
import org.springframework.boot.info.SslInfo;
/**
* An {@link InfoContributor} that exposes {@link SslInfo}.
*
* @author Jonatan Ivanov
* @since 3.4.0
*/
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);
}
}

View File

@ -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.CertificateInfo;
import org.springframework.boot.info.SslInfo.CertificateInfo.Validity;
import static org.springframework.boot.actuate.health.Status.OUT_OF_SERVICE;
import static org.springframework.boot.actuate.health.Status.UP;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.EXPIRED;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.NOT_YET_VALID;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.VALID;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.WILL_EXPIRE_SOON;
/**
* {@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 static final Status WILL_EXPIRE_SOON_STATUS = new Status(WILL_EXPIRE_SOON.name(),
"One of the certificates will expire within the defined threshold.");
private final SslInfo sslInfo;
public SslHealthIndicator(SslInfo sslInfo) {
this.sslInfo = sslInfo;
}
@Override
protected void doHealthCheck(Builder builder) throws Exception {
List<CertificateInfo> notValidCertificates = this.sslInfo.getBundles()
.stream()
.flatMap(bundle -> bundle.getCertificateChains().stream())
.flatMap(certificateChain -> certificateChain.getCertificates().stream())
.filter(certificate -> certificate.getValidity().getStatus() != VALID)
.toList();
if (notValidCertificates.isEmpty()) {
builder.status(UP);
}
else {
Set<Validity.Status> statuses = notValidCertificates.stream()
.map(certificate -> certificate.getValidity().getStatus())
.collect(Collectors.toUnmodifiableSet());
if (statuses.contains(EXPIRED) || statuses.contains(NOT_YET_VALID)) {
builder.status(OUT_OF_SERVICE);
}
else if (statuses.contains(WILL_EXPIRE_SOON)) {
// TODO: Should we introduce Status.WARNING
// (returns 200 but indicates that something is not right)?
builder.status(WILL_EXPIRE_SOON_STATUS);
}
else {
builder.status(OUT_OF_SERVICE);
}
builder.withDetail("certificates", notValidCertificates);
}
}
}

View File

@ -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;

View File

@ -0,0 +1,239 @@
/*
* 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.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map.Entry;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.web.server.Ssl;
import org.springframework.boot.web.server.WebServerSslBundle;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.EXPIRED;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.NOT_YET_VALID;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.VALID;
import static org.springframework.boot.info.SslInfo.CertificateInfo.Validity.Status.WILL_EXPIRE_SOON;
/**
* Information about the certificates that the application uses.
*
* @author Jonatan Ivanov
* @since 3.4.0
*/
public class SslInfo {
private final List<Bundle> bundles;
private final Duration certificateValidityThreshold;
public SslInfo(Ssl ssl, SslBundles sslBundles, Duration certificateValidityThreshold) {
List<Bundle> bundles = new ArrayList<>();
for (Entry<String, SslBundle> entry : sslBundles.getBundles().entrySet()) {
bundles.add(new Bundle(entry.getKey(), entry.getValue()));
}
if (ssl.getBundle() == null) {
// TODO: WebServerSslBundle.get is called at multiple places
// (i.e.: in AbstractConfigurableWebServerFactory#getSslBundle)
// so this is a duplicate, can we create one instance and reuse it
// (e.g.: a bean) or integrate it with SslBundles
// that would make this block unnecessary?
bundles.add(new Bundle("webServerSslBundle", WebServerSslBundle.get(ssl, sslBundles)));
}
this.bundles = Collections.unmodifiableList(bundles);
this.certificateValidityThreshold = certificateValidityThreshold;
}
public List<Bundle> getBundles() {
return this.bundles;
}
public 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 e) {
return List.of();
}
}
private List<Certificate> getCertificates(String alias, KeyStore keyStore) {
try {
return List.of(keyStore.getCertificateChain(alias));
}
catch (KeyStoreException e) {
return List.of();
}
}
}
public 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 class CertificateInfo {
private final X509Certificate certificate;
private CertificateInfo(Certificate certificate) {
// TODO: Is supporting X509Certificate enough? (I _assume_ yes)
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.certificateValidityThreshold)) {
return new Validity(WILL_EXPIRE_SOON, "Certificate will expire within threshold (%s) at %s"
.formatted(SslInfo.this.certificateValidityThreshold, this.getValidityEnds()));
}
else {
return new Validity(VALID, null);
}
}
else {
return null;
}
}
catch (CertificateNotYetValidException exception) {
return new Validity(NOT_YET_VALID, "Not valid before %s".formatted(this.getValidityStarts()));
}
catch (CertificateExpiredException exception) {
return new Validity(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 {
VALID, NOT_YET_VALID, EXPIRED, WILL_EXPIRE_SOON
}
}
}
}

View File

@ -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<String, SslBundle> getBundles() {
return this.registeredBundles.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(Entry::getKey, entry -> entry.getValue().getBundle()));
}
@Override
public void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException {
getRegistered(name).addUpdateHandler(updateHandler);

View File

@ -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<String, SslBundle> getBundles();
/**
* Add a handler that will be called each time the named bundle is updated.
* @param name the bundle name

View File

@ -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")

View File

@ -1,4 +1,14 @@
server.port = 8443
server.ssl.key-store = classpath:sample.jks
server.ssl.key-store-password = secret
server.ssl.key-password = password
server.port=8443
#server.ssl.key-store=classpath:sample.jks
#server.ssl.key-store-password=secret
#server.ssl.key-password=password
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
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