diff --git a/cameleer-server-app/pom.xml b/cameleer-server-app/pom.xml
index b828c850..9ee3c5b8 100644
--- a/cameleer-server-app/pom.xml
+++ b/cameleer-server-app/pom.xml
@@ -144,6 +144,12 @@
awaitility
test
+
+ org.wiremock
+ wiremock-standalone
+ 3.9.1
+ test
+
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java
new file mode 100644
index 00000000..8b6811b6
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java
@@ -0,0 +1,94 @@
+package com.cameleer.server.app.http;
+
+import com.cameleer.server.core.http.OutboundHttpClientFactory;
+import com.cameleer.server.core.http.OutboundHttpProperties;
+import com.cameleer.server.core.http.OutboundHttpRequestContext;
+import com.cameleer.server.core.http.TrustMode;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
+import org.apache.hc.core5.util.Timeout;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Stream;
+
+public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory {
+
+ private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class);
+
+ private final OutboundHttpProperties systemProps;
+ private final SslContextBuilder sslBuilder;
+ private final ConcurrentMap clients = new ConcurrentHashMap<>();
+
+ public ApacheOutboundHttpClientFactory(OutboundHttpProperties systemProps, SslContextBuilder sslBuilder) {
+ this.systemProps = systemProps;
+ this.sslBuilder = sslBuilder;
+ }
+
+ @Override
+ public CloseableHttpClient clientFor(OutboundHttpRequestContext ctx) {
+ CacheKey key = CacheKey.of(systemProps, ctx);
+ return clients.computeIfAbsent(key, k -> build(ctx));
+ }
+
+ private CloseableHttpClient build(OutboundHttpRequestContext ctx) {
+ try {
+ var sslContext = sslBuilder.build(systemProps, ctx);
+ boolean trustAll = systemProps.trustAll() || ctx.trustMode() == TrustMode.TRUST_ALL;
+ var sslFactoryBuilder = SSLConnectionSocketFactoryBuilder.create()
+ .setSslContext(sslContext);
+ if (trustAll) {
+ sslFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE);
+ }
+ var sslFactory = sslFactoryBuilder.build();
+ var connMgr = PoolingHttpClientConnectionManagerBuilder.create()
+ .setSSLSocketFactory(sslFactory)
+ .setDefaultConnectionConfig(ConnectionConfig.custom()
+ .setConnectTimeout(Timeout.of(
+ ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout()))
+ .setSocketTimeout(Timeout.of(
+ ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout()))
+ .build())
+ .build();
+ log.debug("Built outbound HTTP client: trustMode={}, caPaths={}",
+ ctx.trustMode(), ctx.trustedCaPemPaths());
+ return HttpClients.custom()
+ .setConnectionManager(connMgr)
+ .setDefaultRequestConfig(RequestConfig.custom().build())
+ .build();
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to build outbound HTTP client", e);
+ }
+ }
+
+ private record CacheKey(
+ boolean trustAll,
+ List caPaths,
+ TrustMode mode,
+ Duration connect,
+ Duration read
+ ) {
+ static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) {
+ List mergedPaths = Stream.concat(
+ sp.trustedCaPemPaths().stream(),
+ ctx.trustedCaPemPaths().stream()
+ ).toList();
+ return new CacheKey(
+ sp.trustAll(),
+ List.copyOf(mergedPaths),
+ ctx.trustMode(),
+ ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(),
+ ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout()
+ );
+ }
+ }
+}
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java
new file mode 100644
index 00000000..4305d0c6
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java
@@ -0,0 +1,76 @@
+package com.cameleer.server.app.http.config;
+
+import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory;
+import com.cameleer.server.app.http.SslContextBuilder;
+import com.cameleer.server.core.http.OutboundHttpClientFactory;
+import com.cameleer.server.core.http.OutboundHttpProperties;
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+
+@Configuration
+@ConfigurationProperties(prefix = "cameleer.server.outbound-http")
+public class OutboundHttpConfig {
+
+ private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class);
+
+ private boolean trustAll = false;
+ private List trustedCaPemPaths = List.of();
+ private long defaultConnectTimeoutMs = 2000;
+ private long defaultReadTimeoutMs = 5000;
+ private String proxyUrl;
+ private String proxyUsername;
+ private String proxyPassword;
+
+ public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; }
+ public void setTrustedCaPemPaths(List paths) { this.trustedCaPemPaths = paths == null ? List.of() : List.copyOf(paths); }
+ public void setDefaultConnectTimeoutMs(long v) { this.defaultConnectTimeoutMs = v; }
+ public void setDefaultReadTimeoutMs(long v) { this.defaultReadTimeoutMs = v; }
+ public void setProxyUrl(String v) { this.proxyUrl = v; }
+ public void setProxyUsername(String v) { this.proxyUsername = v; }
+ public void setProxyPassword(String v) { this.proxyPassword = v; }
+
+ @PostConstruct
+ void validate() {
+ if (trustAll) {
+ log.warn("cameleer.server.outbound-http.trust-all is ON - all outbound HTTPS cert validation is DISABLED. Do not use in production.");
+ }
+ for (String p : trustedCaPemPaths) {
+ if (!Files.exists(Path.of(p))) {
+ throw new IllegalStateException("Configured trusted CA PEM path does not exist: " + p);
+ }
+ log.info("Outbound HTTP: trusting additional CA from {}", p);
+ }
+ }
+
+ @Bean
+ public OutboundHttpProperties outboundHttpProperties() {
+ return new OutboundHttpProperties(
+ trustAll,
+ trustedCaPemPaths,
+ Duration.ofMillis(defaultConnectTimeoutMs),
+ Duration.ofMillis(defaultReadTimeoutMs),
+ proxyUrl,
+ proxyUsername,
+ proxyPassword
+ );
+ }
+
+ @Bean
+ public SslContextBuilder sslContextBuilder() {
+ return new SslContextBuilder();
+ }
+
+ @Bean
+ public OutboundHttpClientFactory outboundHttpClientFactory(OutboundHttpProperties props, SslContextBuilder builder) {
+ return new ApacheOutboundHttpClientFactory(props, builder);
+ }
+}
diff --git a/cameleer-server-app/src/main/resources/application.yml b/cameleer-server-app/src/main/resources/application.yml
index d0402871..7a73d3a3 100644
--- a/cameleer-server-app/src/main/resources/application.yml
+++ b/cameleer-server-app/src/main/resources/application.yml
@@ -79,6 +79,14 @@ cameleer:
jwkseturi: ${CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI:}
audience: ${CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE:}
tlsskipverify: ${CAMELEER_SERVER_SECURITY_OIDC_TLSSKIPVERIFY:false}
+ outbound-http:
+ trust-all: false
+ trusted-ca-pem-paths: []
+ default-connect-timeout-ms: 2000
+ default-read-timeout-ms: 5000
+ # proxy-url:
+ # proxy-username:
+ # proxy-password:
clickhouse:
url: ${CAMELEER_SERVER_CLICKHOUSE_URL:jdbc:clickhouse://localhost:8123/cameleer}
username: ${CAMELEER_SERVER_CLICKHOUSE_USERNAME:default}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java
new file mode 100644
index 00000000..cd797c5f
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java
@@ -0,0 +1,67 @@
+package com.cameleer.server.app.http;
+
+import com.cameleer.server.core.http.OutboundHttpProperties;
+import com.cameleer.server.core.http.OutboundHttpRequestContext;
+import com.cameleer.server.core.http.TrustMode;
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ApacheOutboundHttpClientFactoryIT {
+
+ private WireMockServer wm;
+ private ApacheOutboundHttpClientFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ wm = new WireMockServer(WireMockConfiguration.options()
+ .httpDisabled(true).dynamicHttpsPort());
+ wm.start();
+ wm.stubFor(get("/ping").willReturn(ok("pong")));
+
+ OutboundHttpProperties props = new OutboundHttpProperties(
+ false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null);
+ factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder());
+ }
+
+ @AfterEach
+ void tearDown() {
+ wm.stop();
+ }
+
+ @Test
+ void trustAllBypassesWiremockSelfSignedCert() throws Exception {
+ CloseableHttpClient client = factory.clientFor(
+ new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null));
+ try (var resp = client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) {
+ assertThat(resp.getCode()).isEqualTo(200);
+ assertThat(EntityUtils.toString(resp.getEntity())).isEqualTo("pong");
+ }
+ }
+
+ @Test
+ void systemDefaultRejectsSelfSignedCert() {
+ CloseableHttpClient client = factory.clientFor(OutboundHttpRequestContext.systemDefault());
+ assertThatThrownBy(() -> client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping")))
+ .hasMessageContaining("PKIX");
+ }
+
+ @Test
+ void clientsAreMemoizedByContext() {
+ CloseableHttpClient c1 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
+ CloseableHttpClient c2 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
+ assertThat(c1).isSameAs(c2);
+ }
+}