Skip to content

Commit e65ff44

Browse files
authored
Merge pull request #783 from splitio/FME-7173-ssl-tunnel
Tunnel establishment
2 parents 921b8e5 + ead5823 commit e65ff44

File tree

2 files changed

+576
-0
lines changed

2 files changed

+576
-0
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package io.split.android.client.network;
2+
3+
import androidx.annotation.NonNull;
4+
import androidx.annotation.Nullable;
5+
6+
import java.io.BufferedReader;
7+
import java.io.IOException;
8+
import java.io.InputStreamReader;
9+
import java.io.OutputStreamWriter;
10+
import java.io.PrintWriter;
11+
import java.net.HttpRetryException;
12+
import java.net.HttpURLConnection;
13+
import java.net.Socket;
14+
import java.nio.charset.StandardCharsets;
15+
16+
import javax.net.ssl.SSLSocket;
17+
import javax.net.ssl.SSLSocketFactory;
18+
19+
import io.split.android.client.utils.logger.Logger;
20+
21+
/**
22+
* Establishes SSL tunnels to SSL proxies using CONNECT protocol.
23+
*/
24+
class SslProxyTunnelEstablisher {
25+
26+
private static final String CRLF = "\r\n";
27+
private static final String PROXY_AUTHORIZATION_HEADER = "Proxy-Authorization";
28+
29+
/**
30+
* Establishes an SSL tunnel through the proxy using the CONNECT method.
31+
* After successful tunnel establishment, extracts the underlying socket
32+
* for use with origin server SSL connections.
33+
*
34+
* @param proxyHost The proxy server hostname
35+
* @param proxyPort The proxy server port
36+
* @param targetHost The target server hostname
37+
* @param targetPort The target server port
38+
* @param sslSocketFactory SSL socket factory for proxy authentication
39+
* @param proxyCredentialsProvider Credentials provider for proxy authentication
40+
* @return Raw socket with tunnel established (connection maintained)
41+
* @throws IOException if tunnel establishment fails
42+
*/
43+
@NonNull
44+
public Socket establishTunnel(@NonNull String proxyHost,
45+
int proxyPort,
46+
@NonNull String targetHost,
47+
int targetPort,
48+
@NonNull SSLSocketFactory sslSocketFactory,
49+
@Nullable ProxyCredentialsProvider proxyCredentialsProvider) throws IOException {
50+
51+
Socket rawSocket = null;
52+
SSLSocket sslSocket = null;
53+
54+
try {
55+
// Step 1: Create raw TCP connection to proxy
56+
rawSocket = new Socket(proxyHost, proxyPort);
57+
rawSocket.setSoTimeout(10000); // 10 second timeout
58+
59+
// Create a temporary SSL socket to establish the SSL session with proper trust validation
60+
sslSocket = (SSLSocket) sslSocketFactory.createSocket(rawSocket, proxyHost, proxyPort, false);
61+
sslSocket.setUseClientMode(true);
62+
sslSocket.setSoTimeout(10000); // 10 second timeout
63+
64+
// Perform SSL handshake using the SSL socket with custom CA certificates
65+
sslSocket.startHandshake();
66+
67+
// Step 3: Send CONNECT request through SSL connection
68+
sendConnectRequest(sslSocket, targetHost, targetPort, proxyCredentialsProvider);
69+
70+
// Step 4: Validate CONNECT response through SSL connection
71+
validateConnectResponse(sslSocket);
72+
Logger.v("SSL tunnel established successfully");
73+
74+
// Step 5: Return SSL socket for tunnel communication
75+
return sslSocket;
76+
77+
} catch (Exception e) {
78+
Logger.e("SSL tunnel establishment failed: " + e.getMessage());
79+
80+
// Clean up resources on error
81+
if (sslSocket != null) {
82+
try {
83+
sslSocket.close();
84+
} catch (IOException closeEx) {
85+
// Ignore close exceptions
86+
}
87+
} else if (rawSocket != null) {
88+
try {
89+
rawSocket.close();
90+
} catch (IOException closeEx) {
91+
// Ignore close exceptions
92+
}
93+
}
94+
95+
if (e instanceof HttpRetryException) {
96+
throw (HttpRetryException) e;
97+
} else if (e instanceof IOException) {
98+
throw (IOException) e;
99+
} else {
100+
throw new IOException("Failed to establish SSL tunnel", e);
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Sends CONNECT request through SSL connection to proxy.
107+
*/
108+
private void sendConnectRequest(@NonNull SSLSocket sslSocket,
109+
@NonNull String targetHost,
110+
int targetPort,
111+
@Nullable ProxyCredentialsProvider proxyCredentialsProvider) throws IOException {
112+
113+
Logger.v("Sending CONNECT request through SSL: CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1");
114+
115+
PrintWriter writer = new PrintWriter(new OutputStreamWriter(sslSocket.getOutputStream(), StandardCharsets.UTF_8), false);
116+
writer.write("CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1" + CRLF);
117+
writer.write("Host: " + targetHost + ":" + targetPort + CRLF);
118+
119+
if (proxyCredentialsProvider != null) {
120+
// Send Proxy-Authorization header if credentials are set
121+
String bearerToken = proxyCredentialsProvider.getBearerToken();
122+
if (bearerToken != null && !bearerToken.trim().isEmpty()) {
123+
writer.write(PROXY_AUTHORIZATION_HEADER + ": Bearer " + bearerToken + CRLF);
124+
}
125+
}
126+
127+
// Send empty line to end headers
128+
writer.write(CRLF);
129+
writer.flush();
130+
131+
Logger.v("CONNECT request sent through SSL connection");
132+
}
133+
134+
/**
135+
* Validates CONNECT response through SSL connection.
136+
* Only reads status line and headers, leaving the stream open for tunneling.
137+
*/
138+
private void validateConnectResponse(@NonNull SSLSocket sslSocket) throws IOException {
139+
140+
Logger.v("Reading CONNECT response through SSL connection");
141+
142+
try {
143+
BufferedReader reader = new BufferedReader(new InputStreamReader(sslSocket.getInputStream(), StandardCharsets.UTF_8));
144+
145+
String statusLine = reader.readLine();
146+
if (statusLine == null) {
147+
throw new IOException("No CONNECT response received from proxy");
148+
}
149+
150+
Logger.v("Received CONNECT response through SSL: " + statusLine.trim());
151+
152+
// Parse status code
153+
String[] statusParts = statusLine.split(" ");
154+
if (statusParts.length < 2) {
155+
throw new IOException("Invalid CONNECT response status line: " + statusLine);
156+
}
157+
158+
int statusCode;
159+
try {
160+
statusCode = Integer.parseInt(statusParts[1]);
161+
} catch (NumberFormatException e) {
162+
throw new IOException("Invalid CONNECT response status code: " + statusLine, e);
163+
}
164+
165+
// Read headers until empty line (but don't process them for CONNECT)
166+
String headerLine;
167+
while ((headerLine = reader.readLine()) != null && !headerLine.trim().isEmpty()) {
168+
Logger.v("CONNECT response header: " + headerLine);
169+
}
170+
171+
// Check status code
172+
if (statusCode != 200) {
173+
if (statusCode == HttpURLConnection.HTTP_PROXY_AUTH) {
174+
throw new HttpRetryException("CONNECT request failed with status " + statusCode + ": " + statusLine, HttpURLConnection.HTTP_PROXY_AUTH);
175+
}
176+
throw new IOException("CONNECT request failed with status " + statusCode + ": " + statusLine);
177+
}
178+
} catch (IOException e) {
179+
if (e instanceof HttpRetryException) {
180+
throw e;
181+
}
182+
183+
throw new IOException("Failed to validate CONNECT response from proxy: " + e.getMessage(), e);
184+
}
185+
}
186+
}

0 commit comments

Comments
 (0)