Skip to content

Commit

Permalink
Add HTTP Proxy Dev Service for REST Client
Browse files Browse the repository at this point in the history
The quarkus-rest-client starts a simple pass-through proxy by default
which can be used as a target for Wireshark (or similar tools)
in order to capture all the traffic originating from the REST Client
(this really makes sense when the REST Client is used against
HTTPS services).

However, advances integrations that build on the REST Client can
register their own proxies by using the
DevServicesRestClientProxyProvider SPI
  • Loading branch information
geoand committed Jul 18, 2024
1 parent ab73220 commit 420d44c
Show file tree
Hide file tree
Showing 13 changed files with 1,239 additions and 153 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,29 @@ public class RestClientBuildConfig {
*/
@ConfigItem
public Optional<String> scope;

/**
* If set to true, then Quarkus will ensure that all calls from the rest client go through a local proxy
* server (that is managed by Quarkus).
* This can be very useful for capturing network traffic to a service that use HTTPS.
* <p>
* This property is not applicable to the RESTEasy Client, only the Quarkus Rest client (formerly RESTEasy Reactive client).
* <p>
* This property only applicable to dev and test mode.
*/
@ConfigItem(defaultValue = "false")
public boolean enableLocalProxy;

/**
* This setting is used to select which proxy provider to use if there are multiple ones.
* It only applies if {@code enable-local-proxy} is true.
* <p>
* The algorithm for picking between multiple provider is the following:
* <ul>
* <li>If only the default is around, use it (it's name is {@code default})</li>
* <li>If there is only one besides the default, use it</li>
* <li>If there are multiple ones, fail</li>
* </ul>
*/
public Optional<String> localProxyProvider;
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public class RestClientConfig {
* This property is only meant to be set by advanced configurations to override whatever value was set for the uri or url.
* The override is done using the REST Client class name configuration syntax.
* <p>
* This property is not applicable to the RESTEasy Client.
* This property is not applicable to the RESTEasy Client, only the Quarkus Rest client (formerly RESTEasy Reactive client).
*/
@ConfigItem
public Optional<String> overrideUri;
Expand Down Expand Up @@ -406,7 +406,7 @@ public static RestClientConfig load(Class<?> interfaceClass) {
return instance;
}

private static <T> Optional<T> getConfigValue(String configKey, String fieldName, Class<T> type) {
public static <T> Optional<T> getConfigValue(String configKey, String fieldName, Class<T> type) {
final Config config = ConfigProvider.getConfig();
Optional<T> optional = config.getOptionalValue(composePropertyKey(configKey, fieldName), type);
if (optional.isEmpty()) { // try to find property with quoted configKey
Expand Down
4 changes: 4 additions & 0 deletions extensions/resteasy-reactive/rest-client/deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-tls-registry-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-http-proxy</artifactId>
</dependency>
<!-- test dependencies: -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.quarkus.rest.client.reactive.deployment;

import java.util.Objects;
import java.util.Optional;

import org.jboss.jandex.ClassInfo;

import io.quarkus.builder.item.MultiBuildItem;

/**
* Contains information about the REST Clients that have been discovered via
* {@link org.eclipse.microprofile.rest.client.inject.RegisterRestClient}
*/
public final class RegisteredRestClientBuildItem extends MultiBuildItem {

private final ClassInfo classInfo;
private final Optional<String> configKey;
private final Optional<String> defaultBaseUri;

public RegisteredRestClientBuildItem(ClassInfo classInfo, Optional<String> configKey, Optional<String> defaultBaseUri) {
this.classInfo = Objects.requireNonNull(classInfo);
this.configKey = Objects.requireNonNull(configKey);
this.defaultBaseUri = Objects.requireNonNull(defaultBaseUri);
}

public ClassInfo getClassInfo() {
return classInfo;
}

public Optional<String> getConfigKey() {
return configKey;
}

public Optional<String> getDefaultBaseUri() {
return defaultBaseUri;
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
package io.quarkus.rest.client.reactive.deployment.devservices;

import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

import org.apache.commons.lang3.exception.UncheckedException;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.IndexView;
import org.jboss.logging.Logger;

import io.quarkus.deployment.IsNormal;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.BuildSteps;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem;
import io.quarkus.deployment.builditem.DevServicesResultBuildItem;
import io.quarkus.rest.client.reactive.deployment.RegisteredRestClientBuildItem;
import io.quarkus.rest.client.reactive.spi.DevServicesRestClientProxyProvider;
import io.quarkus.rest.client.reactive.spi.RestClientHttpProxyBuildItem;
import io.quarkus.restclient.config.RestClientBuildConfig;
import io.quarkus.restclient.config.RestClientConfig;
import io.quarkus.restclient.config.RestClientsBuildTimeConfig;

@BuildSteps(onlyIfNot = IsNormal.class)
public class DevServicesRestClientHttpProxyProcessor {

private static final Logger log = Logger.getLogger(DevServicesRestClientHttpProxyProcessor.class);

// the following fields are needed for state management as proxied can come and go
private static final AtomicReference<Set<RestClientHttpProxyBuildItem>> runningProxies = new AtomicReference<>(
new HashSet<>());
private static final AtomicReference<Set<DevServicesRestClientProxyProvider>> runningProviders = new AtomicReference<>(
Collections.newSetFromMap(new IdentityHashMap<>()));
private static final AtomicReference<Set<Closeable>> providerCloseables = new AtomicReference<>(
Collections.newSetFromMap(new IdentityHashMap<>()));

@BuildStep
public DevServicesRestClientProxyProvider.BuildItem registerDefaultProvider() {
return new DevServicesRestClientProxyProvider.BuildItem(VertxHttpProxyDevServicesRestClientProxyProvider.INSTANCE);
}

@BuildStep
public void determineRequiredProxies(RestClientsBuildTimeConfig restClientsBuildTimeConfig,
CombinedIndexBuildItem combinedIndexBuildItem,
List<RegisteredRestClientBuildItem> registeredRestClientBuildItems,
BuildProducer<RestClientHttpProxyBuildItem> producer) {
if (restClientsBuildTimeConfig.configs.isEmpty()) {
return;
}

IndexView index = combinedIndexBuildItem.getIndex();

Map<String, RestClientBuildConfig> configs = restClientsBuildTimeConfig.configs;
for (var configEntry : configs.entrySet()) {
if (!configEntry.getValue().enableLocalProxy) {
log.trace("Ignoring config key: '" + configEntry.getKey() + "' because enableLocalProxy is false");
break;
}

String configKey = sanitizeKey(configEntry.getKey());

RegisteredRestClientBuildItem matchingBI = null;
// check if the configKey matches one of the @RegisterRestClient values
for (RegisteredRestClientBuildItem bi : registeredRestClientBuildItems) {
if (bi.getConfigKey().isPresent() && configKey.equals(bi.getConfigKey().get())) {
matchingBI = bi;
break;
}
}
if (matchingBI != null) {
Optional<String> baseUri = oneOf(
RestClientConfig.getConfigValue(configKey, "uri", String.class),
RestClientConfig.getConfigValue(configKey, "url", String.class),
matchingBI.getDefaultBaseUri());

if (baseUri.isEmpty()) {
log.debug("Unable to determine uri or url for config key '" + configKey + "'");
break;
}
producer.produce(new RestClientHttpProxyBuildItem(matchingBI.getClassInfo().name().toString(), baseUri.get(),
configEntry.getValue().localProxyProvider));
} else {
// now we check if the configKey was actually a class name
ClassInfo classInfo = index.getClassByName(configKey);
if (classInfo == null) {
log.debug(
"Key '" + configKey + "' could not be matched to either a class name or a REST Client's configKey");
break;
}
Optional<String> baseUri = oneOf(
RestClientConfig.getConfigValue(configKey, "uri", String.class),
RestClientConfig.getConfigValue(configKey, "url", String.class));
if (baseUri.isEmpty()) {
log.debug("Unable to determine uri or url for config key '" + configKey + "'");
break;
}
producer.produce(new RestClientHttpProxyBuildItem(classInfo.name().toString(), baseUri.get(),
configEntry.getValue().localProxyProvider));
}
}
}

private String sanitizeKey(String key) {
if (key.startsWith("\"") && key.endsWith("\"")) {
return key.substring(1, key.length() - 1);
}
return key;
}

@BuildStep
public void start(List<RestClientHttpProxyBuildItem> restClientHttpProxyBuildItems,
List<DevServicesRestClientProxyProvider.BuildItem> restClientProxyProviderBuildItems,
BuildProducer<DevServicesResultBuildItem> devServicePropertiesProducer,
CuratedApplicationShutdownBuildItem closeBuildItem) {
if (restClientHttpProxyBuildItems.isEmpty()) {
return;
}

Set<RestClientHttpProxyBuildItem> requestedProxies = new HashSet<>(restClientHttpProxyBuildItems);

Set<RestClientHttpProxyBuildItem> proxiesToClose = new HashSet<>(runningProxies.get());
proxiesToClose.removeAll(requestedProxies);

// we need to remove the running ones that should no longer be running
for (var running : proxiesToClose) {
closeRunningProxy(running);
}
runningProxies.get().removeAll(proxiesToClose);

// we need to figure out which ones to start
Set<RestClientHttpProxyBuildItem> proxiesToRun = new HashSet<>(requestedProxies);
proxiesToRun.removeAll(runningProxies.get());

// determine which providers to use for each of the new proxies to start
Map<RestClientHttpProxyBuildItem, DevServicesRestClientProxyProvider> biToProviderMap = new HashMap<>();
for (var toStart : proxiesToRun) {
DevServicesRestClientProxyProvider provider;
if (toStart.getProvider().isPresent()) {
String requestedProviderName = toStart.getProvider().get();

var maybeProviderBI = restClientProxyProviderBuildItems
.stream()
.filter(pbi -> requestedProviderName.equals(pbi.getProvider().name()))
.findFirst();
if (maybeProviderBI.isEmpty()) {
throw new RuntimeException("Unable to find provider for REST Client '" + toStart.getClassName()
+ "' with name '" + requestedProviderName + "'");
}

provider = maybeProviderBI.get().getProvider();
} else {
// the algorithm is the following:
// if only the default is around, use it
// if there is only one besides the default, use it
// if there are multiple ones, fail

List<DevServicesRestClientProxyProvider.BuildItem> nonDefault = restClientProxyProviderBuildItems.stream()
.filter(pib -> !pib.getProvider().name().equals(VertxHttpProxyDevServicesRestClientProxyProvider.NAME))
.toList();
if (nonDefault.isEmpty()) {
provider = VertxHttpProxyDevServicesRestClientProxyProvider.INSTANCE;
} else if (nonDefault.size() == 1) {
// TODO: this part of the algorithm is questionable...
provider = nonDefault.iterator().next().getProvider();
} else {
String availableProviders = restClientProxyProviderBuildItems.stream().map(bi -> bi.getProvider().name())
.collect(
Collectors.joining(","));
throw new RuntimeException("Multiple providers found for REST Client '" + toStart.getClassName()
+ "'. Please specify one by setting 'quarkus.rest-client.\"" + toStart.getClassName()
+ "\".local-proxy-provider' to one the following providers: " + availableProviders);
}
}

biToProviderMap.put(toStart, provider);
}

// here is where we set up providers
var providersToRun = new HashSet<>(biToProviderMap.values());
providersToRun.removeAll(runningProviders.get());
for (var provider : providersToRun) {
Closeable closeable = provider.setup();
if (closeable != null) {
providerCloseables.get().add(closeable);
}
runningProviders.get().add(provider);
}

// this is where we actually start proxies
for (var bi : proxiesToRun) {
URI baseUri = URI.create(bi.getBaseUri());

var provider = biToProviderMap.get(bi);
var createResult = provider.create(bi);
var proxyServerClosable = createResult.closeable();
bi.attachClosable(proxyServerClosable);
runningProxies.get().add(bi);

var urlKeyName = String.format("quarkus.rest-client.\"%s\".override-uri", bi.getClassName());
var urlKeyValue = String.format("http://%s:%d", createResult.host(), createResult.port());
if (baseUri.getPath() != null) {
if (!"/".equals(baseUri.getPath()) && !baseUri.getPath().isEmpty()) {
urlKeyValue = urlKeyValue + "/" + baseUri.getPath();
}
}

devServicePropertiesProducer.produce(
new DevServicesResultBuildItem("rest-client-" + bi.getClassName() + "-proxy",
null,
Map.of(urlKeyName, urlKeyValue)));
}

closeBuildItem.addCloseTask(new CloseTask(runningProxies, providerCloseables, runningProviders), true);
}

private static void closeRunningProxy(RestClientHttpProxyBuildItem running) {
try {
Closeable closeable = running.getCloseable();
if (closeable != null) {
log.debug("Attempting to close HTTP proxy server for REST Client '" + running.getClassName() + "'");
closeable.close();
log.debug("Closed HTTP proxy server for REST Client '" + running.getClassName() + "'");
}
} catch (IOException e) {
throw new UncheckedException(e);
}
}

@SafeVarargs
private static <T> Optional<T> oneOf(Optional<T>... optionals) {
for (Optional<T> o : optionals) {
if (o != null && o.isPresent()) {
return o;
}
}
return Optional.empty();
}

private static class CloseTask implements Runnable {

private final AtomicReference<Set<RestClientHttpProxyBuildItem>> runningProxiesRef;
private final AtomicReference<Set<Closeable>> providerCloseablesRef;
private final AtomicReference<Set<DevServicesRestClientProxyProvider>> runningProvidersRef;

public CloseTask(AtomicReference<Set<RestClientHttpProxyBuildItem>> runningProxiesRef,
AtomicReference<Set<Closeable>> providerCloseablesRef,
AtomicReference<Set<DevServicesRestClientProxyProvider>> runningProvidersRef) {

this.runningProxiesRef = runningProxiesRef;
this.providerCloseablesRef = providerCloseablesRef;
this.runningProvidersRef = runningProvidersRef;
}

@Override
public void run() {
Set<RestClientHttpProxyBuildItem> restClientHttpProxyBuildItems = runningProxiesRef.get();
for (var bi : restClientHttpProxyBuildItems) {
closeRunningProxy(bi);
}
runningProxiesRef.set(new HashSet<>());

Set<Closeable> providerCloseables = providerCloseablesRef.get();
for (Closeable closeable : providerCloseables) {
try {
if (closeable != null) {
log.debug("Attempting to close provider");
closeable.close();
log.debug("Closed provider");
}
} catch (IOException e) {
throw new UncheckedException(e);
}
}
providerCloseablesRef.set(Collections.newSetFromMap(new IdentityHashMap<>()));

runningProvidersRef.set(Collections.newSetFromMap(new IdentityHashMap<>()));
}
}
}
Loading

0 comments on commit 420d44c

Please sign in to comment.