This is an automated email from the ASF dual-hosted git repository. ghenzler pushed a commit to branch feature/FELIX-6818-HC-HttpRequestCheck-allow-additional-trusted-certs in repository https://gitbox.apache.org/repos/asf/felix-dev.git
commit 3fabcc88f784be3afa7a34c982bd6a2e6a48cf7a Author: Georg Henzler <[email protected]> AuthorDate: Mon Feb 2 14:18:55 2026 +0100 FELIX-6818 Allow to configure additional trusted certificates --- .../felix/hc/generalchecks/HttpRequestsCheck.java | 42 +++-- .../HttpRequestsCheckTrustedCerts.java | 180 +++++++++++++++++++++ .../hc/generalchecks/HttpRequestsCheckTest.java | 41 ++++- 3 files changed, 249 insertions(+), 14 deletions(-) diff --git a/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheck.java b/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheck.java index 313fc2be85..62777c4413 100644 --- a/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheck.java +++ b/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheck.java @@ -32,10 +32,10 @@ import java.net.Proxy; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -45,6 +45,8 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.net.ssl.HttpsURLConnection; + import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; @@ -129,6 +131,8 @@ public class HttpRequestsCheck implements HealthCheck { @AttributeDefinition(name = "Run in parallel", description = "Run requests in parallel (only active if more than one request spec is configured)") boolean runInParallel() default true; + @AttributeDefinition(name = "Trusted certificates", description = "List of PEM-encoded X.509 certificates to trust for HTTPS requests in addition to the JVM defaults") + String[] trustedCertificates() default {}; @AttributeDefinition String webconsole_configurationFactory_nameHint() default "{hc.name}: {requests}"; @@ -142,6 +146,7 @@ public class HttpRequestsCheck implements HealthCheck { private final int readTimeoutInMs; private final Result.Status statusForFailedContraint; private final boolean runInParallel; + private final HttpRequestsCheckTrustedCerts trustedCerts; private volatile String defaultBaseUrl; private volatile ServiceListener serviceListener; @@ -156,6 +161,9 @@ public class HttpRequestsCheck implements HealthCheck { this.readTimeoutInMs = config.readTimeoutInMs(); this.statusForFailedContraint = config.statusForFailedContraint(); this.runInParallel = config.runInParallel() && requestSpecs.size() > 1; + this.trustedCerts = config.trustedCertificates().length > 0 + ? new HttpRequestsCheckTrustedCerts(config.trustedCertificates(), configErrors) + : null; this.registerServiceListener(); this.setupDefaultBaseUrl(); @@ -218,10 +226,17 @@ public class HttpRequestsCheck implements HealthCheck { overallLog.add(entry); } + if (trustedCerts != null) { + overallLog.debug("Trusted certificates: "); + for(X509Certificate cert: trustedCerts.getTrustedCertificates()) { + overallLog.debug("Cert: " + cert.getSubjectX500Principal().toString()); + } + } + // execute requests Stream<RequestSpec> requestSpecsStream = runInParallel ? requestSpecs.parallelStream() : requestSpecs.stream(); List<FormattingResultLog> logsForEachRequest = requestSpecsStream - .map(requestSpec -> requestSpec.check(defaultBaseUrl, connectTimeoutInMs, readTimeoutInMs, statusForFailedContraint, requestSpecs.size()>1)) + .map(requestSpec -> requestSpec.check(defaultBaseUrl, connectTimeoutInMs, readTimeoutInMs, statusForFailedContraint, requestSpecs.size()>1, trustedCerts)) .collect(Collectors.toList()); // aggregate logs never in parallel @@ -238,10 +253,9 @@ public class HttpRequestsCheck implements HealthCheck { RequestSpec requestSpec = new RequestSpec(requestSpecStr); requestSpecs.add(requestSpec); } catch(Exception e) { - configErrors.critical("Invalid config: {}", requestSpecStr); - configErrors.add(new ResultLog.Entry(Result.Status.CRITICAL, " "+e.getMessage(), e)); - } - + configErrors.healthCheckError("Invalid config: {}", requestSpecStr); + LOG.warn("Invalid config: "+e.getMessage(), e); + } } return requestSpecs; } @@ -390,7 +404,8 @@ public class HttpRequestsCheck implements HealthCheck { return "RequestSpec [method=" + method + ", url=" + url + ", headers=" + headers + ", responseChecks=" + responseChecks + "]"; } - public FormattingResultLog check(String defaultBaseUrl, int connectTimeoutInMs, int readTimeoutInMs, Result.Status statusForFailedContraint, boolean showTiming) { + public FormattingResultLog check(String defaultBaseUrl, int connectTimeoutInMs, int readTimeoutInMs, Result.Status statusForFailedContraint, boolean showTiming, + HttpRequestsCheckTrustedCerts trustedCerts) { FormattingResultLog log = new FormattingResultLog(); if(url.startsWith("/") && (defaultBaseUrl == null || defaultBaseUrl.isEmpty())) { @@ -404,7 +419,7 @@ public class HttpRequestsCheck implements HealthCheck { Response response = null; try { - response = performRequest(defaultBaseUrl, urlWithUser, connectTimeoutInMs, readTimeoutInMs, log); + response = performRequest(defaultBaseUrl, urlWithUser, connectTimeoutInMs, readTimeoutInMs, log, trustedCerts); } catch (IOException e) { // request generally failed log.add(new ResultLog.Entry(statusForFailedContraint, urlWithUser+": "+ e.getMessage(), e)); @@ -427,7 +442,8 @@ public class HttpRequestsCheck implements HealthCheck { return log; } - public Response performRequest(String defaultBaseUrl, String urlWithUser, int connectTimeoutInMs, int readTimeoutInMs, FormattingResultLog log) throws IOException { + public Response performRequest(String defaultBaseUrl, String urlWithUser, int connectTimeoutInMs, int readTimeoutInMs, FormattingResultLog log, + HttpRequestsCheckTrustedCerts trustedCerts) throws IOException { Response response = null; HttpURLConnection conn = null; try { @@ -439,7 +455,7 @@ public class HttpRequestsCheck implements HealthCheck { effectiveUrl = new URL(url); } - conn = openConnection(connectTimeoutInMs, readTimeoutInMs, effectiveUrl, log); + conn = openConnection(connectTimeoutInMs, readTimeoutInMs, effectiveUrl, log, trustedCerts); response = readResponse(conn, log); } finally { @@ -450,12 +466,16 @@ public class HttpRequestsCheck implements HealthCheck { return response; } - private HttpURLConnection openConnection(int defaultConnectTimeoutInMs, int defaultReadTimeoutInMs, URL effectiveUrl, FormattingResultLog log) + private HttpURLConnection openConnection(int defaultConnectTimeoutInMs, int defaultReadTimeoutInMs, URL effectiveUrl, FormattingResultLog log, + HttpRequestsCheckTrustedCerts trustedCerts) throws IOException, ProtocolException { HttpURLConnection conn; conn = (HttpURLConnection) (proxy==null ? effectiveUrl.openConnection() : effectiveUrl.openConnection(proxy)); conn.setInstanceFollowRedirects(false); conn.setUseCaches(false); + if (conn instanceof HttpsURLConnection && trustedCerts != null) { + ((HttpsURLConnection) conn).setSSLSocketFactory(trustedCerts.createSslContext().getSocketFactory()); + } int effectiveConnectTimeout = this.connectTimeoutInMs !=null ? this.connectTimeoutInMs : defaultConnectTimeoutInMs; int effectiveReadTimeout = this.readTimeoutInMs !=null ? this.readTimeoutInMs : defaultReadTimeoutInMs; diff --git a/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTrustedCerts.java b/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTrustedCerts.java new file mode 100644 index 0000000000..187a5c2037 --- /dev/null +++ b/healthcheck/generalchecks/src/main/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTrustedCerts.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The SF licenses this file + * to you 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 + * + * http://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.apache.felix.hc.generalchecks; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import org.apache.felix.hc.api.FormattingResultLog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class HttpRequestsCheckTrustedCerts { + + private static final Logger LOG = LoggerFactory.getLogger(HttpRequestsCheckTrustedCerts.class); + + private final List<X509Certificate> trustedCertificates; + + HttpRequestsCheckTrustedCerts(String[] trustedCertificateConfigs, FormattingResultLog configErrors) { + this.trustedCertificates = parseTrustedCertificates(trustedCertificateConfigs, configErrors); + } + + List<X509Certificate> getTrustedCertificates() { + return trustedCertificates; + } + + SSLContext createSslContext() { + if (trustedCertificates.isEmpty()) { + throw new IllegalStateException("No valid trusted certificates configured"); + } + try { + return createSslContextInternal(trustedCertificates); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not initialize SSL context", e); + } + } + + private SSLContext createSslContextInternal(List<X509Certificate> trustedCertificates) throws GeneralSecurityException { + TrustManagerFactory defaultFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaultFactory.init((KeyStore) null); + X509TrustManager defaultTrustManager = getX509TrustManager(defaultFactory); + + KeyStore trustedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try { + trustedKeyStore.load(null, null); + } catch (IOException e) { + throw new GeneralSecurityException("Could not initialize trust store", e); + } + int index = 0; + for (X509Certificate certificate : trustedCertificates) { + if (certificate != null) { + trustedKeyStore.setCertificateEntry("trusted-cert-" + index, certificate); + index++; + } + } + TrustManagerFactory customFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + customFactory.init(trustedKeyStore); + X509TrustManager customTrustManager = getX509TrustManager(customFactory); + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] { new CompositeX509TrustManager(defaultTrustManager, customTrustManager) }, null); + return context; + } + + private X509TrustManager getX509TrustManager(TrustManagerFactory factory) throws GeneralSecurityException { + for (TrustManager manager : factory.getTrustManagers()) { + if (manager instanceof X509TrustManager) { + return (X509TrustManager) manager; + } + } + throw new GeneralSecurityException("No X509TrustManager available"); + } + + class CompositeX509TrustManager implements X509TrustManager { + private final X509TrustManager defaultTrustManager; + private final X509TrustManager customTrustManager; + + CompositeX509TrustManager(X509TrustManager defaultTrustManager, X509TrustManager customTrustManager) { + this.defaultTrustManager = Objects.requireNonNull(defaultTrustManager, "defaultTrustManager"); + this.customTrustManager = Objects.requireNonNull(customTrustManager, "customTrustManager"); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + defaultTrustManager.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + defaultTrustManager.checkServerTrusted(chain, authType); + } catch (CertificateException e) { + customTrustManager.checkServerTrusted(chain, authType); + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + X509Certificate[] defaultIssuers = defaultTrustManager.getAcceptedIssuers(); + X509Certificate[] customIssuers = customTrustManager.getAcceptedIssuers(); + X509Certificate[] combined = Arrays.copyOf(defaultIssuers, defaultIssuers.length + customIssuers.length); + System.arraycopy(customIssuers, 0, combined, defaultIssuers.length, customIssuers.length); + return combined; + } + } + + private List<X509Certificate> parseTrustedCertificates(String[] trustedCertificateConfigs, FormattingResultLog configErrors) { + if (trustedCertificateConfigs == null || trustedCertificateConfigs.length == 0) { + return Collections.emptyList(); + } + List<X509Certificate> certificates = new ArrayList<>(); + + CertificateFactory certificateFactory; + try { + certificateFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + LOG.error("Could not initialize certificate parser: {}", e.getMessage(), e); + return java.util.Collections.emptyList(); + } + for (String certificateText : trustedCertificateConfigs) { + if (certificateText == null) { + continue; + } + String trimmed = certificateText.trim(); + if (trimmed.isEmpty()) { + continue; + } + try { + if (trimmed.contains("BEGIN CERTIFICATE")) { + java.util.Collection<? extends Certificate> parsed = certificateFactory.generateCertificates( + new java.io.ByteArrayInputStream(trimmed.getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + for (Certificate cert : parsed) { + if (cert instanceof X509Certificate) { + certificates.add((X509Certificate) cert); + } + } + } else { + byte[] decoded = java.util.Base64.getMimeDecoder().decode(trimmed); + Certificate cert = certificateFactory.generateCertificate(new java.io.ByteArrayInputStream(decoded)); + if (cert instanceof X509Certificate) { + certificates.add((X509Certificate) cert); + } + } + } catch (Exception e) { + LOG.error("Invalid trusted certificate entry: {}", e.getMessage(), e); + configErrors.healthCheckError("Invalid trusted certificate entry: " + e.getMessage(), e); + } + } + return certificates; + } +} diff --git a/healthcheck/generalchecks/src/test/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTest.java b/healthcheck/generalchecks/src/test/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTest.java index 3b37a771b3..4a27196d09 100644 --- a/healthcheck/generalchecks/src/test/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTest.java +++ b/healthcheck/generalchecks/src/test/java/org/apache/felix/hc/generalchecks/HttpRequestsCheckTest.java @@ -22,6 +22,9 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; @@ -115,8 +118,8 @@ public class HttpRequestsCheckTest { private Entry fakeRequestForSpecAndReturnResponse(HttpRequestsCheck.RequestSpec requestSpecOrig, HttpRequestsCheck.Response response) throws Exception { RequestSpec requestSpec = Mockito.spy(requestSpecOrig); - doReturn(response).when(requestSpec).performRequest(anyString(), anyString(), anyInt(), anyInt(), any(FormattingResultLog.class)); - FormattingResultLog resultLog = requestSpec.check("http://localhost:8080", 10000, 10000, Result.Status.WARN, true); + doReturn(response).when(requestSpec).performRequest(anyString(), anyString(), anyInt(), anyInt(), any(FormattingResultLog.class), any(HttpRequestsCheckTrustedCerts.class)); + FormattingResultLog resultLog = requestSpec.check("http://localhost:8080", 10000, 10000, Result.Status.WARN, true, null); Iterator<Entry> entryIt = resultLog.iterator(); Entry lastEntry = null; while(entryIt.hasNext()) { @@ -207,7 +210,7 @@ public class HttpRequestsCheckTest { @Test public void testRelativeUrlWithoutHttpServiceReturnsUnavailableLog() throws Exception { HttpRequestsCheck.RequestSpec requestSpec = new HttpRequestsCheck.RequestSpec("/path/to/page.html"); - FormattingResultLog resultLog = requestSpec.check(null, 1000, 1000, Result.Status.WARN, false); + FormattingResultLog resultLog = requestSpec.check(null, 1000, 1000, Result.Status.WARN, false, null); Iterator<Entry> entryIt = resultLog.iterator(); Entry lastEntry = null; @@ -219,6 +222,33 @@ public class HttpRequestsCheckTest { assertThat(lastEntry.getMessage(), containsString("HttpService is not available")); } + @Test + public void testCreateSslContextWithoutCertificates() { + FormattingResultLog configErrors = new FormattingResultLog(); + HttpRequestsCheckTrustedCerts trustedCerts = new HttpRequestsCheckTrustedCerts(new String[0], configErrors); + assertFalse("No config error expected", configErrors.iterator().hasNext()); + try { + trustedCerts.createSslContext(); + fail("Expected IllegalStateException for empty trusted certificates"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("No valid trusted certificates configured")); + } + } + + @Test + public void testCreateSslContextWithInvalidCertificate() { + FormattingResultLog configErrors = new FormattingResultLog(); + HttpRequestsCheckTrustedCerts trustedCerts = new HttpRequestsCheckTrustedCerts(new String[] { "not-a-cert" }, configErrors); + assertTrue("Config error expected", configErrors.iterator().hasNext()); + + try { + trustedCerts.createSslContext(); + fail("Expected IllegalStateException for invalid trusted certificates"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("No valid trusted certificates configured")); + } + } + private HttpRequestsCheck.Config createConfig() { return new HttpRequestsCheck.Config() { @Override @@ -256,6 +286,11 @@ public class HttpRequestsCheckTest { return false; } + @Override + public String[] trustedCertificates() { + return new String[0]; + } + @Override public String webconsole_configurationFactory_nameHint() { return "{hc.name}: {requests}";
