Skip to content

Commit

Permalink
Add initializer API for headers of HTTP proxy CONNECT request
Browse files Browse the repository at this point in the history
Motivation:

After apple#2697 moved HTTP proxy `CONNECT` logic before user-defined
`ConnectionFactoryFilter`s, users lost the ability to intercept
`CONNECT` requests for the purpose of adding custom headers, like auth,
tracing, etc.

Modifications:

- Introduce `ProxyConfig` and `ProxyConfigBuilder` in `http-api` that
can be used to provide additional options for proxy behavior;
- Add `SingleAddressHttpClientBuilder.proxyConfig(...)` overload that
takes `ProxyConfig`;
- Deprecate pre-existing `SingleAddressHttpClientBuilder.proxyAddress()`
method, recommend switching to new API;
- Enhance `HttpsProxyTest` to verify that new API can be used to send
`Proxy-Authorization` header;

Result:

Users have explicit API to alter HTTP `CONNECT` request headers, if
necessary.
  • Loading branch information
idelpivnitskiy committed Nov 7, 2023
1 parent 99ea1c5 commit 46ae4e4
Show file tree
Hide file tree
Showing 21 changed files with 305 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.servicetalk.grpc.api.GrpcStatusCode;
import io.servicetalk.grpc.api.GrpcStatusException;
import io.servicetalk.http.api.HttpResponseStatus;
import io.servicetalk.http.api.ProxyConfig;
import io.servicetalk.http.api.ProxyConnectResponseException;
import io.servicetalk.http.api.StreamingHttpConnectionFilter;
import io.servicetalk.http.api.StreamingHttpRequest;
Expand Down Expand Up @@ -89,7 +90,7 @@ class GrpcProxyTunnelTest {
.listenAndAwait((Greeter.BlockingGreeterService) (ctx, request) ->
HelloReply.newBuilder().setMessage(GREETING_PREFIX + request.getName()).build());
client = GrpcClients.forAddress(serverHostAndPort(serverContext))
.initializeHttp(httpBuilder -> httpBuilder.proxyAddress(proxyAddress)
.initializeHttp(httpBuilder -> httpBuilder.proxyConfig(ProxyConfig.of(proxyAddress))
.sslConfig(new ClientSslConfigBuilder(DefaultTestCerts::loadServerCAPem)
.peerHost(serverPemHostname()).build())
.appendConnectionFilter(connection -> new StreamingHttpConnectionFilter(connection) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,18 @@ public String toString() {
}

@Override
@SuppressWarnings("deprecation")
public SingleAddressHttpClientBuilder<U, R> proxyAddress(final U proxyAddress) {
delegate = delegate.proxyAddress(proxyAddress);
return this;
}

@Override
public SingleAddressHttpClientBuilder<U, R> proxyConfig(final ProxyConfig<U> proxyConfig) {
delegate = delegate.proxyConfig(proxyConfig);
return this;
}

@Override
public <T> SingleAddressHttpClientBuilder<U, R> socketOption(final SocketOption<T> option, final T value) {
delegate = delegate.socketOption(option, value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public final class HttpContextKeys {
* <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling#http_tunneling">secure
* HTTP proxy tunneling</a> and a clear text HTTP proxy, check presence of {@link ConnectionInfo#sslConfig()}.
*
* @see SingleAddressHttpClientBuilder#proxyAddress(Object)
* @see SingleAddressHttpClientBuilder#proxyConfig(ProxyConfig)
* @deprecated Use {@link TransportObserverConnectionFactoryFilter} to configure {@link TransportObserver} and then
* listen {@link ConnectionObserver#onProxyConnect(Object)} callback to distinguish between a regular connection and
* a connection to the secure HTTP proxy tunnel. For clear text HTTP proxies, consider installing a custom client
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright © 2023 Apple Inc. and the ServiceTalk project authors
*
* 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 io.servicetalk.http.api;

import io.servicetalk.transport.api.ClientSslConfig;

import java.util.function.Consumer;

/**
* Configuration for a proxy.
*
* @param <A> the type of address
*/
public interface ProxyConfig<A> {

/**
* Address of the proxy.
* <p>
* Usually, this is an unresolved proxy address and its type must match the type of address before resolution used
* by {@link SingleAddressHttpClientBuilder}. However, if the client builder was created for a resolved address,
* this address must also be already resolved. Otherwise, a runtime exceptions will occur.
*
* @return address of the proxy
*/
A address();

/**
* An initializer for {@link HttpHeaders} related to
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
* <p>
* When this {@link ProxyConfig} is used for secure proxy tunnels (when
* {@link SingleAddressHttpClientBuilder#sslConfig(ClientSslConfig) ClientSslConfig} is configured) the tunnel is
* always initialized using {@code HTTP/1.1 CONNECT} request. This {@link Consumer} can be used to set custom
* {@link HttpHeaders} for that request, like {@link HttpHeaderNames#PROXY_AUTHORIZATION proxy-authorization},
* tracing, or any other header.
*
* @return An initializer for {@link HttpHeaders} related to
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request
*/
Consumer<HttpHeaders> connectRequestHeadersInitializer();

/**
* Returns a {@link ProxyConfig} for the specified {@code address}.
* <p>
* All other {@link ProxyConfig} options will use their default values applied by {@link ProxyConfigBuilder}.
*
* @param address Address of the proxy
* @param <A> the type of address
* @return a {@link ProxyConfig} for the specified {@code address}
* @see ProxyConfigBuilder
*/
static <A> ProxyConfig<A> of(A address) {
return new ProxyConfigBuilder<>(address).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright © 2023 Apple Inc. and the ServiceTalk project authors
*
* 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 io.servicetalk.http.api;

import java.util.function.Consumer;

import static java.util.Objects.requireNonNull;

/**
* Builder for {@link ProxyConfig}.
*
* @param <A> the type of address
*/
public final class ProxyConfigBuilder<A> {

private final A address;
private Consumer<HttpHeaders> connectRequestHeadersInitializer = __ -> { };

/**
* Creates a new instance.
*
* @param address Proxy address
* @see ProxyConfig#address()
*/
public ProxyConfigBuilder(final A address) {
this.address = requireNonNull(address);
}

/**
* Sets an initializer for {@link HttpHeaders} related to
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
*
* @param connectRequestHeadersInitializer {@link Consumer} that can be used to set custom {@link HttpHeaders} for
* {@code HTTP/1.1 CONNECT} request (auth, tracing, etc.)
* @return {@code this}
* @see ProxyConfig#connectRequestHeadersInitializer()
*/
public ProxyConfigBuilder<A> connectRequestHeadersInitializer(
final Consumer<HttpHeaders> connectRequestHeadersInitializer) {
this.connectRequestHeadersInitializer = requireNonNull(connectRequestHeadersInitializer);
return this;
}

/**
* Builds a new {@link ProxyConfig}.
*
* @return a new {@link ProxyConfig}.
*/
public ProxyConfig<A> build() {
return new DefaultProxyConfig<>(address, connectRequestHeadersInitializer);
}

private static final class DefaultProxyConfig<A> implements ProxyConfig<A> {

private final A address;
private final Consumer<HttpHeaders> connectRequestHeadersInitializer;

private DefaultProxyConfig(final A address, final Consumer<HttpHeaders> connectRequestHeadersInitializer) {
this.address = address;
this.connectRequestHeadersInitializer = connectRequestHeadersInitializer;
}

@Override
public A address() {
return address;
}

@Override
public Consumer<HttpHeaders> connectRequestHeadersInitializer() {
return connectRequestHeadersInitializer;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* An exception while processing
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
*
* @see SingleAddressHttpClientBuilder#proxyAddress(Object)
* @see SingleAddressHttpClientBuilder#proxyConfig(ProxyConfig)
*/
public class ProxyConnectException extends IOException {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* A subclass of {@link ProxyConnectException} that indicates an unexpected response status from a proxy received for
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
*
* @see SingleAddressHttpClientBuilder#proxyAddress(Object)
* @see SingleAddressHttpClientBuilder#proxyConfig(ProxyConfig)
*/
public class ProxyConnectResponseException extends ProxyConnectException {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ public RedirectConfigBuilder redirectRequestTransformer(final RedirectRequestTra
return this;
}

/**
* Builds a new {@link RedirectConfig}.
*
* @return a new {@link RedirectConfig}
*/
public RedirectConfig build() {
return new DefaultRedirectConfig(maxRedirects,
allowedStatuses == null ? DEFAULT_ALLOWED_STATUSES : toSet(allowedStatuses),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
*/
public interface SingleAddressHttpClientBuilder<U, R> extends HttpClientBuilder<U, R, ServiceDiscovererEvent<R>> {
/**
* Configure proxy to serve as an intermediary for requests.
* Configures a proxy to serve as an intermediary for requests.
* <p>
* If the client talks to a proxy over http (not https, {@link #sslConfig(ClientSslConfig) ClientSslConfig} is NOT
* configured), it will rewrite the request-target to
Expand All @@ -65,10 +65,40 @@ public interface SingleAddressHttpClientBuilder<U, R> extends HttpClientBuilder<
* @param proxyAddress Unresolved address of the proxy. When used with a builder created for a resolved address,
* {@code proxyAddress} should also be already resolved – otherwise runtime exceptions may occur.
* @return {@code this}.
* @deprecated Use {@link #proxyConfig(ProxyConfig)} with {@link ProxyConfig#of(Object)}.
*/
default SingleAddressHttpClientBuilder<U, R> proxyAddress(U proxyAddress) { // FIXME: 0.43 - remove default impl
throw new UnsupportedOperationException("Setting proxy address is not yet supported by "
+ getClass().getName());
// FIXME: 0.43 - remove deprecated method
@Deprecated
@SuppressWarnings("DeprecatedIsStillUsed")
default SingleAddressHttpClientBuilder<U, R> proxyAddress(U proxyAddress) {
proxyConfig(new ProxyConfigBuilder<>(proxyAddress).build());
return this;
}

/**
* Configures a proxy to serve as an intermediary for requests.
* <p>
* If the client talks to a proxy over http (not https, {@link #sslConfig(ClientSslConfig) ClientSslConfig} is NOT
* configured), it will rewrite the request-target to
* <a href="https://tools.ietf.org/html/rfc7230#section-5.3.2">absolute-form</a>, as specified by the RFC.
* <p>
* For secure proxy tunnels (when {@link #sslConfig(ClientSslConfig) ClientSslConfig} is configured) the tunnel is
* always initialized using
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request. The actual
* protocol will be negotiated via <a href="https://tools.ietf.org/html/rfc7301">ALPN extension</a> of TLS protocol,
* taking into account HTTP protocols configured via {@link #protocols(HttpProtocolConfig...)} method. In case of
* any error during {@code CONNECT} process, {@link ProxyConnectException} or {@link ProxyConnectResponseException}
* will be thrown when a request attempt is made through the constructed client instance.
*
* @param proxyConfig Configuration for a proxy. When used with a builder created for a resolved address,
* {@link ProxyConfig#address()} must also be already resolved – otherwise runtime exceptions will occur.
* @return {@code this}.
* @see ProxyConfig#of(Object)
* @see ProxyConfigBuilder
*/
// FIXME: 0.43 - consider removing default impl
default SingleAddressHttpClientBuilder<U, R> proxyConfig(ProxyConfig<U> proxyConfig) {
throw new UnsupportedOperationException("Setting proxy config is not yet supported by " + getClass().getName());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import io.servicetalk.http.api.HttpLoadBalancerFactory;
import io.servicetalk.http.api.HttpProtocolConfig;
import io.servicetalk.http.api.HttpProtocolVersion;
import io.servicetalk.http.api.ProxyConfig;
import io.servicetalk.http.api.SingleAddressHttpClientBuilder;
import io.servicetalk.http.api.StreamingHttpClient;
import io.servicetalk.http.api.StreamingHttpClientFilter;
Expand Down Expand Up @@ -108,7 +109,7 @@ final class DefaultSingleAddressHttpClientBuilder<U, R> implements SingleAddress
@Nullable
private final U address;
@Nullable
private U proxyAddress;
private ProxyConfig<U> proxyConfig;
private final HttpClientConfig config;
final HttpExecutionContextBuilder executionContextBuilder;
private final ClientStrategyInfluencerChainBuilder strategyComputation;
Expand Down Expand Up @@ -147,7 +148,7 @@ final class DefaultSingleAddressHttpClientBuilder<U, R> implements SingleAddress
private DefaultSingleAddressHttpClientBuilder(@Nullable final U address,
final DefaultSingleAddressHttpClientBuilder<U, R> from) {
this.address = address;
proxyAddress = from.proxyAddress;
proxyConfig = from.proxyConfig;
config = new HttpClientConfig(from.config);
executionContextBuilder = new HttpExecutionContextBuilder(from.executionContextBuilder);
strategyComputation = from.strategyComputation.copy();
Expand Down Expand Up @@ -191,7 +192,7 @@ private static final class HttpClientBuildContext<U, R> {

U address() {
assert builder.address != null : "Attempted to buildStreaming with an unknown address";
return builder.proxyAddress != null ? builder.proxyAddress : builder.address;
return builder.proxyConfig != null ? builder.proxyConfig.address() : builder.address;
}

HttpClientConfig httpConfig() {
Expand Down Expand Up @@ -387,8 +388,9 @@ private static StreamingHttpRequestResponseFactory defaultReqRespFactory(ReadOnl

private static <U, R> String targetAddress(final HttpClientBuildContext<U, R> ctx) {
assert ctx.builder.address != null;
return ctx.builder.proxyAddress == null ?
ctx.builder.address.toString() : ctx.builder.address + " (via " + ctx.builder.proxyAddress + ")";
return ctx.builder.proxyConfig == null ?
ctx.builder.address.toString() :
ctx.builder.address + " (via " + ctx.builder.proxyConfig.address() + ")";
}

private static ContextAwareStreamingHttpClientFilterFactory appendFilter(
Expand Down Expand Up @@ -446,9 +448,9 @@ private AbsoluteAddressHttpRequesterFilter proxyAbsoluteAddressFilterFactory() {
}

@Override
public DefaultSingleAddressHttpClientBuilder<U, R> proxyAddress(final U proxyAddress) {
this.proxyAddress = requireNonNull(proxyAddress);
config.connectAddress(hostToCharSequenceFunction.apply(address));
public DefaultSingleAddressHttpClientBuilder<U, R> proxyConfig(final ProxyConfig<U> proxyConfig) {
this.proxyConfig = requireNonNull(proxyConfig);
config.proxy(proxyConfig, hostToCharSequenceFunction.apply(address));
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.servicetalk.http.netty;

import io.servicetalk.http.api.Http2Settings;
import io.servicetalk.http.api.ProxyConfig;
import io.servicetalk.tcp.netty.internal.TcpClientConfig;
import io.servicetalk.transport.api.ClientSslConfig;
import io.servicetalk.transport.api.DelegatingClientSslConfig;
Expand All @@ -27,12 +28,15 @@
import static io.servicetalk.http.netty.HttpServerConfig.httpAlpnProtocols;
import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV4Address;
import static io.servicetalk.utils.internal.NetworkUtils.isValidIpV6Address;
import static java.util.Objects.requireNonNull;

final class HttpClientConfig {

private final TcpClientConfig tcpConfig;
private final HttpConfig protocolConfigs;
@Nullable
private ProxyConfig<?> proxyConfig;
@Nullable
private CharSequence connectAddress;
@Nullable
private String fallbackPeerHost;
Expand Down Expand Up @@ -61,6 +65,7 @@ final class HttpClientConfig {
HttpClientConfig(final HttpClientConfig from) {
tcpConfig = new TcpClientConfig(from.tcpConfig());
protocolConfigs = new HttpConfig(from.protocolConfigs());
proxyConfig = from.proxyConfig;
connectAddress = from.connectAddress;
fallbackPeerHost = from.fallbackPeerHost;
fallbackPeerPort = from.fallbackPeerPort;
Expand All @@ -77,13 +82,19 @@ HttpConfig protocolConfigs() {
return protocolConfigs;
}

@Nullable
ProxyConfig<?> proxyConfig() {
return proxyConfig;
}

@Nullable
CharSequence connectAddress() {
return connectAddress;
}

void connectAddress(@Nullable final CharSequence connectAddress) {
this.connectAddress = connectAddress;
void proxy(final ProxyConfig<?> proxyConfig, final CharSequence connectAddress) {
this.proxyConfig = requireNonNull(proxyConfig);
this.connectAddress = requireNonNull(connectAddress);
}

void fallbackPeerHost(@Nullable String fallbackPeerHost) {
Expand Down
Loading

0 comments on commit 46ae4e4

Please sign in to comment.