Skip to content

Commit

Permalink
Dialogue client channel creation may target a specific address (#2134)
Browse files Browse the repository at this point in the history
Dialogue client channel creation may target a specific address
  • Loading branch information
carterkozak authored Feb 13, 2024
1 parent 9d4b185 commit 0a8e6df
Show file tree
Hide file tree
Showing 11 changed files with 433 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .palantir/revapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ acceptedBreaks:
new: "parameter <V> V com.palantir.dialogue.ResponseAttachments::put(===com.palantir.dialogue.ResponseAttachmentKey<V>===,\
\ V)"
justification: "Incorrect parameter types"
"3.110.0":
com.palantir.dialogue:dialogue-core:
- code: "java.method.addedToInterface"
new: "method java.util.Optional<java.net.InetAddress> com.palantir.dialogue.core.DialogueChannelFactory.ChannelArgs::resolvedAddress()"
justification: "Type is consumed, not extended"
"3.2.0":
com.palantir.dialogue:dialogue-clients:
- code: "java.method.addedToInterface"
Expand Down
5 changes: 5 additions & 0 deletions changelog/@unreleased/pr-2134.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type: improvement
improvement:
description: Dialogue client channel creation may target a specific address
links:
- https://github.com/palantir/dialogue/pull/2134
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -78,16 +79,19 @@ final class ApacheHttpClientBlockingChannel implements BlockingChannel {

private final ApacheHttpClientChannels.CloseableClient client;
private final BaseUrl baseUrl;
private final Optional<InetAddress> resolvedHost;
private final ResponseLeakDetector responseLeakDetector;
private final OptionalInt uriIndexForInstrumentation;

ApacheHttpClientBlockingChannel(
ApacheHttpClientChannels.CloseableClient client,
URL baseUrl,
Optional<InetAddress> resolvedHost,
ResponseLeakDetector responseLeakDetector,
OptionalInt uriIndexForInstrumentation) {
this.client = client;
this.baseUrl = BaseUrl.of(baseUrl);
this.resolvedHost = resolvedHost;
this.responseLeakDetector = responseLeakDetector;
this.uriIndexForInstrumentation = uriIndexForInstrumentation;
}
Expand Down Expand Up @@ -117,6 +121,7 @@ public Response execute(Endpoint endpoint, Request request) throws IOException {
long startTime = System.nanoTime();
try {
HttpClientContext context = HttpClientContext.create();
resolvedHost.ifPresent(inetAddress -> DialogueRoutePlanner.set(context, inetAddress));
CloseableHttpResponse httpClientResponse = client.apacheClient().execute(builder.build(), context);
// Defensively ensure that resources are closed if failures occur within this block,
// for example HttpClientResponse allocation may throw an OutOfMemoryError.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.ManagedHttpClientConnectionFactory;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner;
import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
Expand Down Expand Up @@ -130,7 +129,11 @@ public static Channel create(ClientConfiguration conf, String channelName) {

public static Channel createSingleUri(DialogueChannelFactory.ChannelArgs args, CloseableClient client) {
BlockingChannel blockingChannel = new ApacheHttpClientBlockingChannel(
client, url(args.uri()), client.leakDetector(), args.uriIndexForInstrumentation());
client,
url(args.uri()),
args.resolvedAddress(),
client.leakDetector(),
args.uriIndexForInstrumentation());
return client.executor() == null
? BlockingChannelAdapter.of(blockingChannel)
: BlockingChannelAdapter.of(blockingChannel, client.executor());
Expand Down Expand Up @@ -544,7 +547,7 @@ public Socket createSocket(HttpContext _context) {
.setKeepAliveStrategy(
new InactivityValidationAwareConnectionKeepAliveStrategy(internalConnectionManager, name))
.setConnectionManager(connectionManager)
.setRoutePlanner(new SystemDefaultRoutePlanner(null, conf.proxy()))
.setRoutePlanner(new DialogueRoutePlanner(conf.proxy()))
.disableAutomaticRetries()
// Must be disabled otherwise connections are not reused when client certificates are provided
.disableConnectionState()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* (c) Copyright 2024 Palantir Technologies Inc. All rights reserved.
*
* Licensed 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 com.palantir.dialogue.hc5;

import java.net.InetAddress;
import java.net.ProxySelector;
import javax.annotation.Nullable;
import org.apache.hc.client5.http.HttpRoute;
import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner;
import org.apache.hc.client5.http.routing.HttpRoutePlanner;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.protocol.HttpContext;

/**
* This implementation wraps the default route planner, but allows a specific pre-resolved address to be used.
* Client instances are shared between all URIs, so this route planner cannot know resolved addresses at the
* moment it's created, and instead must rely on callers passing the resolved address using the
* {@link HttpContext}.
*/
final class DialogueRoutePlanner implements HttpRoutePlanner {
private static final String ATTRIBUTE = "dialogueResolvedAddress";
private final HttpRoutePlanner delegate;

DialogueRoutePlanner(ProxySelector proxySelector) {
delegate = new SystemDefaultRoutePlanner(proxySelector);
}

@Override
public HttpRoute determineRoute(HttpHost host, HttpContext context) throws HttpException {
HttpRoute route = delegate.determineRoute(host, context);
if (route.getTargetHost().getAddress() == null) {
InetAddress resolvedAddress = get(context);
if (resolvedAddress != null) {
return withResolvedAddress(route, resolvedAddress);
}
}
return route;
}

private static HttpRoute withResolvedAddress(HttpRoute route, InetAddress resolvedAddress) {
HttpHost targetHost = route.getTargetHost();
return new HttpRoute(
new HttpHost(
targetHost.getSchemeName(), resolvedAddress, targetHost.getHostName(), targetHost.getPort()),
route.getLocalAddress(),
// We don't really expect proxies to be used with pre-resolved addresses, however
// that takes place at a different layer of the implementation.
extractProxies(route),
route.isSecure(),
route.getTunnelType(),
route.getLayerType());
}

@Nullable
private static HttpHost[] extractProxies(HttpRoute route) {
int hops = route.getHopCount();
if (hops > 1) {
HttpHost[] proxies = new HttpHost[hops - 1];
for (int i = 0; i < hops - 1; i++) {
proxies[i] = route.getHopTarget(i);
}
return proxies;
}
return null;
}

static void set(HttpContext context, InetAddress resolvedAddress) {
context.setAttribute(ATTRIBUTE, resolvedAddress);
}

@Nullable
private static InetAddress get(HttpContext context) {
Object value = context.getAttribute(ATTRIBUTE);
if (value instanceof InetAddress) {
return (InetAddress) value;
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* (c) Copyright 2024 Palantir Technologies Inc. All rights reserved.
*
* Licensed 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 com.palantir.dialogue.hc5;

import static org.assertj.core.api.Assertions.assertThat;

import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.ListenableFuture;
import com.palantir.conjure.java.client.config.ClientConfiguration;
import com.palantir.dialogue.Channel;
import com.palantir.dialogue.Request;
import com.palantir.dialogue.Response;
import com.palantir.dialogue.TestConfigurations;
import com.palantir.dialogue.TestEndpoint;
import com.palantir.dialogue.TestOnlyCertificates;
import com.palantir.dialogue.core.DialogueChannelFactory.ChannelArgs;
import com.palantir.dialogue.hc5.ApacheHttpClientChannels.CloseableClient;
import io.undertow.Undertow;
import io.undertow.server.handlers.ResponseCodeHandler;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.UUID;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLPeerUnverifiedException;
import org.junit.jupiter.api.Test;

final class ResolvedAddressTest {

@Test
void testPlainHttp() throws Exception {
Undertow undertow = Undertow.builder()
.addHttpListener(0, null, new ResponseCodeHandler(204))
.build();
undertow.start();
try (CloseableClient client =
ApacheHttpClientChannels.createCloseableHttpClient(TestConfigurations.create(), "test")) {
int port = port(undertow);
String hostname = UUID.randomUUID().toString();
String uri = "http://" + hostname + ':' + port;
InetAddress resolved = InetAddress.getByAddress(hostname, new byte[] {127, 0, 0, 1});
Channel channel = ApacheHttpClientChannels.createSingleUri(
ChannelArgs.builder().uri(uri).resolvedAddress(resolved).build(), client);
ListenableFuture<Response> future =
channel.execute(TestEndpoint.GET, Request.builder().build());
try (Response response = future.get()) {
assertThat(response.code()).isEqualTo(204);
}
} finally {
undertow.stop();
}
}

@Test
void testTls() throws Exception {
String hostname = UUID.randomUUID().toString();

TestOnlyCertificates.GeneratedKeyPair keyPair = TestOnlyCertificates.generate(hostname);

SSLContext context = TestOnlyCertificates.toContext(keyPair, true);

ClientConfiguration config = ClientConfiguration.builder()
.from(TestConfigurations.create())
.sslSocketFactory(context.getSocketFactory())
.trustManager(TestOnlyCertificates.toTrustManager(keyPair))
.build();

Undertow undertow = Undertow.builder()
.addHttpsListener(0, null, context, new ResponseCodeHandler(204))
.build();
undertow.start();
try (CloseableClient client = ApacheHttpClientChannels.createCloseableHttpClient(config, "test")) {
int port = port(undertow);
String uri = "https://" + hostname + ':' + port;
InetAddress resolved = InetAddress.getByAddress(hostname, new byte[] {127, 0, 0, 1});
Channel channel = ApacheHttpClientChannels.createSingleUri(
ChannelArgs.builder().uri(uri).resolvedAddress(resolved).build(), client);
ListenableFuture<Response> future =
channel.execute(TestEndpoint.GET, Request.builder().build());
try (Response response = future.get()) {
assertThat(response.code()).isEqualTo(204);
}
} finally {
undertow.stop();
}
}

@Test
void testTlsWithUnexpectedHostname() throws Exception {
String hostname = UUID.randomUUID().toString();
TestOnlyCertificates.GeneratedKeyPair keyPair = TestOnlyCertificates.generate("localhost");

SSLContext context = TestOnlyCertificates.toContext(keyPair, true);

ClientConfiguration config = ClientConfiguration.builder()
.from(TestConfigurations.create())
.sslSocketFactory(context.getSocketFactory())
.trustManager(TestOnlyCertificates.toTrustManager(keyPair))
.build();

Undertow undertow = Undertow.builder()
.addHttpsListener(0, null, context, new ResponseCodeHandler(204))
.build();
undertow.start();
try (CloseableClient client = ApacheHttpClientChannels.createCloseableHttpClient(config, "test")) {
int port = port(undertow);
String uri = "https://" + hostname + ':' + port;
InetAddress resolved = InetAddress.getByAddress(hostname, new byte[] {127, 0, 0, 1});
Channel channel = ApacheHttpClientChannels.createSingleUri(
ChannelArgs.builder().uri(uri).resolvedAddress(resolved).build(), client);
ListenableFuture<Response> future =
channel.execute(TestEndpoint.GET, Request.builder().build());
assertThat(future)
.failsWithin(Duration.ofSeconds(5))
.withThrowableThat()
.withRootCauseInstanceOf(SSLPeerUnverifiedException.class);
} finally {
undertow.stop();
}
}

private static int port(Undertow undertow) {
return ((InetSocketAddress)
Iterables.getOnlyElement(undertow.getListenerInfo()).getAddress())
.getPort();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import com.palantir.dialogue.Channel;
import com.palantir.dialogue.DialogueImmutablesStyle;
import java.net.InetAddress;
import java.util.Optional;
import java.util.OptionalInt;
import org.immutables.value.Value;

Expand All @@ -30,6 +32,8 @@ public interface DialogueChannelFactory {
interface ChannelArgs {
String uri();

Optional<InetAddress> resolvedAddress();

OptionalInt uriIndexForInstrumentation();

static Builder builder() {
Expand Down
2 changes: 2 additions & 0 deletions dialogue-test-common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ dependencies {
api 'org.mockito:mockito-core'
api 'org.mockito:mockito-junit-jupiter'
api 'io.undertow:undertow-core'

implementation 'org.bouncycastle:bcpkix-jdk18on'
}

tasks.withType(JavaCompile) {
Expand Down
Loading

0 comments on commit 0a8e6df

Please sign in to comment.