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}";

Reply via email to