Skip to content

Commit 02a49b6

Browse files
committed
Add a MeterBinder for SSL chain expiry
It registers a 'ssl.chains' gauge to count the number of chains with different statuses (valid, expired, not yet valid, will expire soon). Additionally, it registers a 'ssl.chain.expiry' gauge for every certificate in a chain, tracking the seconds until expiry. This binder reacts on bundle updates and new bundle registrations. Closes gh-42030
1 parent 4685fde commit 02a49b6

File tree

10 files changed

+590
-1
lines changed

10 files changed

+590
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.ssl;
18+
19+
import java.time.Clock;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.time.temporal.ChronoUnit;
23+
import java.util.ArrayList;
24+
import java.util.Collection;
25+
import java.util.Comparator;
26+
import java.util.EnumSet;
27+
import java.util.HashSet;
28+
import java.util.List;
29+
import java.util.Map;
30+
import java.util.Set;
31+
import java.util.concurrent.ConcurrentHashMap;
32+
33+
import io.micrometer.core.instrument.Gauge;
34+
import io.micrometer.core.instrument.MeterRegistry;
35+
import io.micrometer.core.instrument.MultiGauge;
36+
import io.micrometer.core.instrument.MultiGauge.Row;
37+
import io.micrometer.core.instrument.Tags;
38+
import io.micrometer.core.instrument.TimeGauge;
39+
import io.micrometer.core.instrument.binder.MeterBinder;
40+
41+
import org.springframework.boot.info.SslInfo;
42+
import org.springframework.boot.info.SslInfo.BundleInfo;
43+
import org.springframework.boot.info.SslInfo.CertificateChainInfo;
44+
import org.springframework.boot.info.SslInfo.CertificateInfo;
45+
import org.springframework.boot.info.SslInfo.CertificateValidityInfo;
46+
import org.springframework.boot.info.SslInfo.CertificateValidityInfo.Status;
47+
import org.springframework.boot.ssl.SslBundles;
48+
49+
/**
50+
* {@link MeterBinder} which registers the SSL chain validity (soonest to expire
51+
* certificate in the chain) as a {@link TimeGauge}. Also contributes two {@link Gauge
52+
* gauges} to count the valid and invalid chains.
53+
*
54+
* @author Moritz Halbritter
55+
*/
56+
class SslMeterBinder implements MeterBinder {
57+
58+
private static final String CHAINS_METRIC_NAME = "ssl.chains";
59+
60+
private static final String CHAIN_EXPIRY_METRIC_NAME = "ssl.chain.expiry";
61+
62+
private final Clock clock;
63+
64+
private final SslInfo sslInfo;
65+
66+
private final BundleMetrics bundleMetrics = new BundleMetrics();
67+
68+
SslMeterBinder(SslInfo sslInfo, SslBundles sslBundles) {
69+
this(sslInfo, sslBundles, Clock.systemDefaultZone());
70+
}
71+
72+
SslMeterBinder(SslInfo sslInfo, SslBundles sslBundles, Clock clock) {
73+
this.clock = clock;
74+
this.sslInfo = sslInfo;
75+
sslBundles.addBundleRegisterHandler((bundleName, ignored) -> onBundleChange(bundleName));
76+
for (String bundleName : sslBundles.getBundleNames()) {
77+
sslBundles.addBundleUpdateHandler(bundleName, (ignored) -> onBundleChange(bundleName));
78+
}
79+
}
80+
81+
private void onBundleChange(String bundleName) {
82+
BundleInfo bundle = this.sslInfo.getBundle(bundleName);
83+
this.bundleMetrics.updateBundle(bundle);
84+
for (MeterRegistry meterRegistry : this.bundleMetrics.getMeterRegistries()) {
85+
createOrUpdateBundleMetrics(meterRegistry, bundle);
86+
}
87+
}
88+
89+
@Override
90+
public void bindTo(MeterRegistry meterRegistry) {
91+
for (BundleInfo bundle : this.sslInfo.getBundles()) {
92+
createOrUpdateBundleMetrics(meterRegistry, bundle);
93+
}
94+
Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.VALID))
95+
.tag("status", "valid")
96+
.register(meterRegistry);
97+
Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.EXPIRED))
98+
.tag("status", "expired")
99+
.register(meterRegistry);
100+
Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.NOT_YET_VALID))
101+
.tag("status", "not-yet-valid")
102+
.register(meterRegistry);
103+
Gauge.builder(CHAINS_METRIC_NAME, () -> countChainsByStatus(Status.WILL_EXPIRE_SOON))
104+
.tag("status", "will-expire-soon")
105+
.register(meterRegistry);
106+
}
107+
108+
private void createOrUpdateBundleMetrics(MeterRegistry meterRegistry, BundleInfo bundle) {
109+
MultiGauge multiGauge = this.bundleMetrics.getGauge(bundle, meterRegistry);
110+
List<Row<CertificateInfo>> rows = new ArrayList<>();
111+
for (CertificateChainInfo chain : bundle.getCertificateChains()) {
112+
Row<CertificateInfo> row = createRowForChain(bundle, chain);
113+
if (row != null) {
114+
rows.add(row);
115+
}
116+
}
117+
multiGauge.register(rows, true);
118+
}
119+
120+
private Row<CertificateInfo> createRowForChain(BundleInfo bundle, CertificateChainInfo chain) {
121+
CertificateInfo leastValidCertificate = chain.getCertificates()
122+
.stream()
123+
.min(Comparator.comparing(CertificateInfo::getValidityEnds))
124+
.orElse(null);
125+
if (leastValidCertificate == null) {
126+
return null;
127+
}
128+
Tags tags = Tags.of("chain", chain.getAlias(), "bundle", bundle.getName(), "certificate",
129+
leastValidCertificate.getSerialNumber());
130+
return Row.of(tags, leastValidCertificate, this::getChainExpiry);
131+
}
132+
133+
private long countChainsByStatus(Status status) {
134+
long count = 0;
135+
for (BundleInfo bundle : this.bundleMetrics.getBundles()) {
136+
for (CertificateChainInfo chain : bundle.getCertificateChains()) {
137+
if (getChainStatus(chain) == status) {
138+
count++;
139+
}
140+
}
141+
}
142+
return count;
143+
}
144+
145+
private Status getChainStatus(CertificateChainInfo chain) {
146+
EnumSet<Status> statuses = EnumSet.noneOf(Status.class);
147+
for (CertificateInfo certificate : chain.getCertificates()) {
148+
CertificateValidityInfo validity = certificate.getValidity();
149+
statuses.add(validity.getStatus());
150+
}
151+
if (statuses.contains(Status.EXPIRED)) {
152+
return Status.EXPIRED;
153+
}
154+
if (statuses.contains(Status.NOT_YET_VALID)) {
155+
return Status.NOT_YET_VALID;
156+
}
157+
if (statuses.contains(Status.WILL_EXPIRE_SOON)) {
158+
return Status.WILL_EXPIRE_SOON;
159+
}
160+
return statuses.isEmpty() ? null : Status.VALID;
161+
}
162+
163+
private long getChainExpiry(CertificateInfo certificate) {
164+
Duration valid = Duration.between(Instant.now(this.clock), certificate.getValidityEnds());
165+
return valid.get(ChronoUnit.SECONDS);
166+
}
167+
168+
/**
169+
* Manages bundles and their metrics.
170+
*/
171+
private static final class BundleMetrics {
172+
173+
private final Map<String, Gauges> gauges = new ConcurrentHashMap<>();
174+
175+
/**
176+
* Gets (or creates) a {@link MultiGauge} for the given bundle and meter registry.
177+
* @param bundleInfo the bundle
178+
* @param meterRegistry the meter registry
179+
* @return the {@link MultiGauge}
180+
*/
181+
MultiGauge getGauge(BundleInfo bundleInfo, MeterRegistry meterRegistry) {
182+
Gauges gauges = this.gauges.computeIfAbsent(bundleInfo.getName(),
183+
(ignored) -> Gauges.emptyGauges(bundleInfo));
184+
return gauges.getGauge(meterRegistry);
185+
}
186+
187+
/**
188+
* Returns all bundles.
189+
* @return all bundles
190+
*/
191+
Collection<BundleInfo> getBundles() {
192+
List<BundleInfo> result = new ArrayList<>();
193+
for (Gauges metrics : this.gauges.values()) {
194+
result.add(metrics.bundle());
195+
}
196+
return result;
197+
}
198+
199+
/**
200+
* Returns all meter registries.
201+
* @return all meter registries
202+
*/
203+
Collection<MeterRegistry> getMeterRegistries() {
204+
Set<MeterRegistry> result = new HashSet<>();
205+
for (Gauges metrics : this.gauges.values()) {
206+
result.addAll(metrics.getMeterRegistries());
207+
}
208+
return result;
209+
}
210+
211+
/**
212+
* Updates the given bundle.
213+
* @param bundle the updated bundle
214+
*/
215+
void updateBundle(BundleInfo bundle) {
216+
this.gauges.computeIfPresent(bundle.getName(), (key, oldValue) -> oldValue.withBundle(bundle));
217+
}
218+
219+
/**
220+
* Manages the {@link MultiGauge MultiGauges} associated to a bundle.
221+
*
222+
* @param bundle the bundle
223+
* @param multiGauges mapping from meter registry to {@link MultiGauge}
224+
*/
225+
private record Gauges(BundleInfo bundle, Map<MeterRegistry, MultiGauge> multiGauges) {
226+
227+
/**
228+
* Gets (or creates) the {@link MultiGauge} for the given meter registry.
229+
* @param meterRegistry the meter registry
230+
* @return the {@link MultiGauge}
231+
*/
232+
MultiGauge getGauge(MeterRegistry meterRegistry) {
233+
return this.multiGauges.computeIfAbsent(meterRegistry, (ignored) -> createGauge(meterRegistry));
234+
}
235+
236+
/**
237+
* Returns a copy of this bundle with an updated {@link BundleInfo}.
238+
* @param bundle the updated {@link BundleInfo}
239+
* @return the copy of this bundle with an updated {@link BundleInfo}
240+
*/
241+
Gauges withBundle(BundleInfo bundle) {
242+
return new Gauges(bundle, this.multiGauges);
243+
}
244+
245+
/**
246+
* Returns all meter registries.
247+
* @return all meter registries
248+
*/
249+
Set<MeterRegistry> getMeterRegistries() {
250+
return this.multiGauges.keySet();
251+
}
252+
253+
private MultiGauge createGauge(MeterRegistry meterRegistry) {
254+
return MultiGauge.builder(CHAIN_EXPIRY_METRIC_NAME)
255+
.baseUnit("seconds")
256+
.description("SSL chain expiry")
257+
.register(meterRegistry);
258+
}
259+
260+
/**
261+
* Creates an instance with an empty gauge mapping.
262+
* @param bundle the {@link BundleInfo} associated with the new instance
263+
* @return the new instance
264+
*/
265+
static Gauges emptyGauges(BundleInfo bundle) {
266+
return new Gauges(bundle, new ConcurrentHashMap<>());
267+
}
268+
}
269+
270+
}
271+
272+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.autoconfigure.ssl;
18+
19+
import io.micrometer.core.instrument.MeterRegistry;
20+
21+
import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
22+
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
23+
import org.springframework.boot.autoconfigure.AutoConfiguration;
24+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
25+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
26+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
27+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
28+
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
29+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
30+
import org.springframework.boot.info.SslInfo;
31+
import org.springframework.boot.ssl.SslBundles;
32+
import org.springframework.context.annotation.Bean;
33+
34+
/**
35+
* {@link EnableAutoConfiguration Auto-configuration} for SSL observability.
36+
*
37+
* @author Moritz Halbritter
38+
* @since 3.5.0
39+
*/
40+
@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
41+
SslAutoConfiguration.class })
42+
@ConditionalOnClass(MeterRegistry.class)
43+
@ConditionalOnBean({ MeterRegistry.class, SslBundles.class })
44+
@EnableConfigurationProperties(SslHealthIndicatorProperties.class)
45+
public class SslObservabilityAutoConfiguration {
46+
47+
@Bean
48+
SslMeterBinder sslMeterBinder(SslInfo sslInfo, SslBundles sslBundles) {
49+
return new SslMeterBinder(sslInfo, sslBundles);
50+
}
51+
52+
@Bean
53+
@ConditionalOnMissingBean
54+
SslInfo sslInfoProvider(SslBundles sslBundles, SslHealthIndicatorProperties properties) {
55+
return new SslInfo(sslBundles, properties.getCertificateValidityWarningThreshold());
56+
}
57+
58+
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSec
104104
org.springframework.boot.actuate.autoconfigure.session.SessionsEndpointAutoConfiguration
105105
org.springframework.boot.actuate.autoconfigure.startup.StartupEndpointAutoConfiguration
106106
org.springframework.boot.actuate.autoconfigure.ssl.SslHealthContributorAutoConfiguration
107+
org.springframework.boot.actuate.autoconfigure.ssl.SslObservabilityAutoConfiguration
107108
org.springframework.boot.actuate.autoconfigure.system.DiskSpaceHealthContributorAutoConfiguration
108109
org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration
109110
org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration

0 commit comments

Comments
 (0)