Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions servicetalk-examples/docs/modules/ROOT/pages/http/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ Using the following classes:
NOTE: This example uses the link:#blocking-aggregated[blocking + aggregated] API, as the TLS/SSL configuration API is
the same across all the HTTP APIs.

[#HTTPS-Proxy]
== HTTPS Proxy

This example demonstrates how a client can reach an origin server through an HTTP `CONNECT` proxy that itself requires
mutual TLS. Two independent TLS configurations are involved: the outer handshake authenticates the client to the proxy,
and the inner handshake performs standard server-auth TLS with the origin, tunneled over the established `CONNECT`.

Using the following classes:

- link:{source-root}/servicetalk-examples/http/https-proxy/src/main/java/io/servicetalk/examples/http/httpsproxy/HttpsProxyServer.java[HttpsProxyServer] - Starts an origin HTTPS server and a mutual-TLS `CONNECT` proxy in front of it.
- link:{source-root}/servicetalk-examples/http/https-proxy/src/main/java/io/servicetalk/examples/http/httpsproxy/HttpsProxyClient.java[HttpsProxyClient] - A client that configures mutual TLS to the proxy and server-auth TLS to the origin.
- link:{source-root}/servicetalk-examples/http/https-proxy/src/main/java/io/servicetalk/examples/http/httpsproxy/HandshakeLoggingTransportObserver.java[HandshakeLoggingTransportObserver] - A `TransportObserver` that logs both the proxy and origin TLS handshakes.

NOTE: This example uses the link:#blocking-aggregated[blocking + aggregated] API, as the TLS/SSL configuration API is
the same across all the HTTP APIs.

[#Observer]
== Observer
This example demonstrates the following:
Expand Down
36 changes: 36 additions & 0 deletions servicetalk-examples/http/https-proxy/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright © 2026 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.
*/

apply plugin: "java"
apply from: "../../gradle/idea.gradle"

dependencies {
implementation platform("io.netty:netty-bom:$nettyVersion")

implementation project(":servicetalk-annotations")
implementation project(":servicetalk-client-api")
implementation project(":servicetalk-http-netty")
// ProxyTunnel, an in-process mutual-TLS CONNECT proxy, lives in this module's test fixtures. It is used here only
// to make the example self-contained; production code never depends on test fixtures.
implementation testFixtures(project(":servicetalk-http-netty"))

// This dependency brings self-signed TLS certificates for demonstration purposes.
// In real applications users have to provide their own certificates instead.
implementation project(":servicetalk-test-resources")

implementation "org.slf4j:slf4j-api:$slf4jVersion"
runtimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:$log4jVersion"
}
46 changes: 46 additions & 0 deletions servicetalk-examples/http/https-proxy/gradle.lockfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :servicetalk-examples:http:servicetalk-examples-http-https-proxy:dependencies --write-locks
com.google.code.findbugs:jsr305:3.0.2=compileClasspath,runtimeClasspath
io.netty:netty-bom:4.2.16.Final=compileClasspath,runtimeClasspath
io.netty:netty-buffer:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-base:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-compression:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-dns:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-http2:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-http:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-marshalling:4.2.16.Final=runtimeClasspath
io.netty:netty-codec-protobuf:4.2.16.Final=runtimeClasspath
io.netty:netty-codec:4.2.16.Final=runtimeClasspath
io.netty:netty-common:4.2.16.Final=runtimeClasspath
io.netty:netty-handler:4.2.16.Final=runtimeClasspath
io.netty:netty-resolver-dns-classes-macos:4.2.16.Final=runtimeClasspath
io.netty:netty-resolver-dns-native-macos:4.2.16.Final=runtimeClasspath
io.netty:netty-resolver-dns:4.2.16.Final=runtimeClasspath
io.netty:netty-resolver:4.2.16.Final=runtimeClasspath
io.netty:netty-tcnative-boringssl-static:2.0.78.Final=runtimeClasspath
io.netty:netty-tcnative-classes:2.0.78.Final=runtimeClasspath
io.netty:netty-transport-classes-epoll:4.2.16.Final=runtimeClasspath
io.netty:netty-transport-classes-io_uring:4.2.16.Final=runtimeClasspath
io.netty:netty-transport-classes-kqueue:4.2.16.Final=runtimeClasspath
io.netty:netty-transport-native-epoll:4.2.16.Final=runtimeClasspath
io.netty:netty-transport-native-io_uring:4.2.16.Final=runtimeClasspath
io.netty:netty-transport-native-kqueue:4.2.16.Final=runtimeClasspath
io.netty:netty-transport-native-unix-common:4.2.16.Final=runtimeClasspath
io.netty:netty-transport:4.2.16.Final=runtimeClasspath
net.bytebuddy:byte-buddy-agent:1.12.19=runtimeClasspath
net.bytebuddy:byte-buddy:1.12.19=runtimeClasspath
org.apache.logging.log4j:log4j-api:2.24.3=runtimeClasspath
org.apache.logging.log4j:log4j-core:2.24.3=runtimeClasspath
org.apache.logging.log4j:log4j-slf4j-impl:2.24.3=runtimeClasspath
org.hamcrest:hamcrest:2.2=compileClasspath,runtimeClasspath
org.jctools:jctools-core:4.0.6=runtimeClasspath
org.junit.jupiter:junit-jupiter-api:5.14.1=runtimeClasspath
org.junit.platform:junit-platform-commons:1.14.1=runtimeClasspath
org.junit:junit-bom:5.14.1=runtimeClasspath
org.mockito:mockito-core:4.11.0=runtimeClasspath
org.objenesis:objenesis:3.3=runtimeClasspath
org.opentest4j:opentest4j:1.3.0=runtimeClasspath
org.slf4j:slf4j-api:1.7.36=compileClasspath,runtimeClasspath
empty=annotationProcessor
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright © 2026 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.examples.http.httpsproxy;

import io.servicetalk.transport.api.ConnectionObserver;
import io.servicetalk.transport.api.SslConfig;
import io.servicetalk.transport.api.TransportObserver;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import javax.net.ssl.SSLSession;

/**
* Logs the outcome of both TLS handshakes performed when CONNECTing through a TLS proxy: the outer handshake with the
* proxy itself ({@link ConnectionObserver#onProxySecurityHandshake}) and the inner handshake with the origin server
* tunneled over the established CONNECT ({@link ConnectionObserver#onSecurityHandshake}).
*/
final class HandshakeLoggingTransportObserver implements TransportObserver {

private static final Logger LOGGER = LoggerFactory.getLogger(HandshakeLoggingTransportObserver.class);

@Override
public ConnectionObserver onNewConnection(@Nullable final Object localAddress, final Object remoteAddress) {
return new ConnectionObserver() {
@Override
public SecurityHandshakeObserver onProxySecurityHandshake(final SslConfig sslConfig) {
LOGGER.info("Proxy TLS handshake starting against {}", remoteAddress);
return new LoggingHandshakeObserver("proxy", remoteAddress);
}

@Override
public SecurityHandshakeObserver onSecurityHandshake(final SslConfig sslConfig) {
LOGGER.info("Origin TLS handshake starting (tunneled through {})", remoteAddress);
return new LoggingHandshakeObserver("origin", remoteAddress);
}
};
}

private static final class LoggingHandshakeObserver implements ConnectionObserver.SecurityHandshakeObserver {

private final String which;
private final Object remoteAddress;

LoggingHandshakeObserver(final String which, final Object remoteAddress) {
this.which = which;
this.remoteAddress = remoteAddress;
}

@Override
public void handshakeComplete(final SSLSession sslSession) {
LOGGER.info("{} TLS handshake complete against {}: protocol={}, cipher={}, peer={}",
which, remoteAddress, sslSession.getProtocol(), sslSession.getCipherSuite(),
peerPrincipal(sslSession));
}

@Override
public void handshakeFailed(final Throwable cause) {
LOGGER.warn("{} TLS handshake failed against {}", which, remoteAddress, cause);
}

private static String peerPrincipal(final SSLSession session) {
try {
return session.getPeerPrincipal().getName();
} catch (Exception e) {
return "<unavailable: " + e.getMessage() + ">";
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright © 2026 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.examples.http.httpsproxy;

import io.servicetalk.client.api.TransportObserverConnectionFactoryFilter;
import io.servicetalk.http.api.BlockingHttpClient;
import io.servicetalk.http.api.HttpResponse;
import io.servicetalk.http.api.ProxyConfigBuilder;
import io.servicetalk.http.netty.HttpClients;
import io.servicetalk.test.resources.DefaultTestCerts;
import io.servicetalk.transport.api.ClientSslConfigBuilder;
import io.servicetalk.transport.api.HostAndPort;

import static io.servicetalk.examples.http.httpsproxy.HttpsProxyServer.ORIGIN_PORT;
import static io.servicetalk.examples.http.httpsproxy.HttpsProxyServer.PROXY_PORT;
import static io.servicetalk.http.api.HttpSerializers.textSerializerUtf8;

/**
* A client that reaches an origin server through a mutual-TLS HTTP CONNECT proxy: it presents a client certificate to
* the proxy (outer TLS) and does ordinary server-auth TLS to the origin (inner TLS) over the established tunnel.
* <p>
* Run {@link HttpsProxyServer} first.
*/
public final class HttpsProxyClient {

private HttpsProxyClient() {
}

public static void main(String[] args) throws Exception {
// Note: DefaultTestCerts contains self-signed certificates that may be used only for local testing
// or demonstration purposes. Never use those for real use-cases.
try (BlockingHttpClient client = HttpClients.forSingleAddress("localhost", ORIGIN_PORT)
.proxyConfig(new ProxyConfigBuilder<>(HostAndPort.of("localhost", PROXY_PORT))
// Outer TLS to the proxy: trust the proxy's certificate and present our own to authenticate.
.sslConfig(new ClientSslConfigBuilder(DefaultTestCerts::loadServerCAPem)
.keyManager(DefaultTestCerts::loadClientPem, DefaultTestCerts::loadClientKey).build())
.build())
// Inner TLS to the origin, tunneled through the CONNECT: standard server authentication.
.sslConfig(new ClientSslConfigBuilder(DefaultTestCerts::loadServerCAPem).build())
// Optional: log both TLS handshakes (proxy and origin) as they complete.
.appendConnectionFactoryFilter(new TransportObserverConnectionFactoryFilter<>(
new HandshakeLoggingTransportObserver()))
.buildBlocking()) {
HttpResponse response = client.request(client.get("/"));
System.out.println(response.toString((name, value) -> value));
System.out.println(response.payloadBody(textSerializerUtf8()));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright © 2026 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.examples.http.httpsproxy;

import io.servicetalk.http.netty.HttpServers;
import io.servicetalk.http.netty.ProxyTunnel;
import io.servicetalk.test.resources.DefaultTestCerts;
import io.servicetalk.transport.api.ServerSslConfigBuilder;

import java.io.InputStream;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;

import static io.servicetalk.http.api.HttpSerializers.textSerializerUtf8;

/**
* Starts the two server-side components used by this example: an origin HTTPS server and a mutual-TLS
* {@link ProxyTunnel CONNECT proxy} in front of it. Run this before {@link HttpsProxyClient}.
*/
public final class HttpsProxyServer {

static final int ORIGIN_PORT = 8080;
static final int PROXY_PORT = 8081;

private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray();

private HttpsProxyServer() {
}

public static void main(String[] args) throws Exception {
// The proxy terminates the outer TLS handshake (requiring a client certificate) and then blindly tunnels the
// inner, end-to-end TLS session between the client and the origin. ProxyTunnel is a test fixture reused here
// to keep the example self-contained.
final ProxyTunnel proxyTunnel = new ProxyTunnel();
proxyTunnel.sslContext(proxyMtlsSslContext());
proxyTunnel.needClientAuth(true);
proxyTunnel.startProxy(PROXY_PORT);

// A regular origin HTTPS server (server-auth TLS only); the proxy forwards the tunneled bytes here.
HttpServers.forPort(ORIGIN_PORT)
// Note: DefaultTestCerts contains self-signed certificates that may be used only for local testing
// or demonstration purposes. Never use those for real use-cases.
.sslConfig(new ServerSslConfigBuilder(DefaultTestCerts::loadServerPem, DefaultTestCerts::loadServerKey)
.build())
.listenBlockingAndAwait((ctx, request, responseFactory) -> responseFactory.ok()
.payloadBody("Reached the origin through a mutual-TLS CONNECT proxy!", textSerializerUtf8()))
.awaitShutdown();
}

// The proxy's mutual-TLS SSLContext: server identity from the shared "localhost" test certificate, trusting
// client certificates issued by the example client CA.
private static SSLContext proxyMtlsSslContext() throws Exception {
final KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (InputStream in = DefaultTestCerts.loadServerP12()) {
keyStore.load(in, KEYSTORE_PASSWORD);
}
final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, KEYSTORE_PASSWORD);

final KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
try (InputStream in = DefaultTestCerts.loadClientCAPem()) {
trustStore.setCertificateEntry("client-ca",
CertificateFactory.getInstance("X.509").generateCertificate(in));
}
final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);

final SSLContext context = SSLContext.getInstance("TLS");
context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
return context;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright © 2026 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.
*/
/**
* Runnable client demonstrating layered TLS through an HTTPS CONNECT proxy: mutual TLS to the proxy, then a tunneled
* TLS handshake with the origin server. Both handshakes are surfaced through a {@code TransportObserver}.
*/
@ElementsAreNonnullByDefault
package io.servicetalk.examples.http.httpsproxy;

import io.servicetalk.annotations.ElementsAreNonnullByDefault;
Loading
Loading