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); + } +}