Skip to content

Commit 43102e2

Browse files
adietishcursoragent
andcommitted
fix: route TLS trust probe through IDE HTTP proxy (CRW-12333)
TlsConnectionProbe opened a raw SSLSocket and bypassed IdeHttpProxy, so proxy-only clusters timed out even when Check connection worked. Use the IDE ProxySelector (HTTP CONNECT, Basic on 407) and surface connect failures clearly instead of treating them as trust prompts. Signed-off-by: Andre Dietisheim <adietish@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 14bce0f commit 43102e2

14 files changed

Lines changed: 955 additions & 39 deletions

src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,19 @@ import com.redhat.devtools.gateway.util.toServerBaseUrl
1919
import io.kubernetes.client.util.KubeConfig
2020
import kotlinx.coroutines.Dispatchers
2121
import kotlinx.coroutines.withContext
22+
import java.io.IOException
2223
import java.net.URI
2324
import java.security.cert.X509Certificate
2425
import javax.net.ssl.SSLContext
26+
import javax.net.ssl.SSLException
2527
import javax.net.ssl.SSLHandshakeException
2628

2729
class DefaultTlsTrustManager(
2830
private val kubeConfigProvider: suspend () -> List<KubeConfig>,
2931
private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List<X509Certificate>) -> Unit,
3032
private val sessionTrustStore: SessionTlsTrustStore,
3133
private val persistentKeyStore: PersistentKeyStore,
32-
private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsProbe.connect(uri, ctx.sslContext) },
34+
private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsConnectionProbe.connect(uri, ctx.sslContext) },
3335
private val oauthDiscovery: suspend (String, SSLContext) -> List<String> = { apiBaseUrl, sslContext ->
3436
OAuthDiscovery(apiBaseUrl, sslContext).endpointBaseUrls()
3537
}
@@ -105,6 +107,10 @@ class DefaultTlsTrustManager(
105107
} catch (e: SSLHandshakeException) {
106108
thisLogger().debug("TLS trust: JVM CAs do not trust $serverUrl (${e.message})")
107109
null
110+
} catch (e: SSLException) {
111+
throw e
112+
} catch (e: IOException) {
113+
throwConnectionError(serverUrl, e)
108114
}
109115
}
110116

@@ -134,6 +140,10 @@ class DefaultTlsTrustManager(
134140
"TLS trust: handshake failed with known certificate(s) for $serverUrl; will prompt (${e.message})"
135141
)
136142
null
143+
} catch (e: SSLException) {
144+
throw e
145+
} catch (e: IOException) {
146+
throwConnectionError(serverUrl, e)
137147
}
138148
}
139149

@@ -153,18 +163,32 @@ class DefaultTlsTrustManager(
153163
null // probe succeeded without throwing — no cert info, caller logs unexpected success
154164
} catch (e: SSLHandshakeException) {
155165
val chain = (captureContext.trustManager as? CapturingTrustManager)
156-
?.serverCertificateChain?.toList() ?: throw e
166+
?.serverCertificateChain?.toList()
167+
?.takeIf { it.isNotEmpty() }
168+
?: throw e
157169

158170
val trustAnchor = chain.first()
159171
CapturedCertInfo(
160-
problem = if (trustedCerts.isEmpty()) TlsTrustProblem.UNTRUSTED_CERTIFICATE
161-
else TlsTrustProblem.CERTIFICATE_CHANGED,
172+
problem = if (trustedCerts.isEmpty()) {
173+
TlsTrustProblem.UNTRUSTED_CERTIFICATE
174+
} else {
175+
TlsTrustProblem.CERTIFICATE_CHANGED
176+
},
162177
chain = chain,
163178
trustAnchor = trustAnchor,
164179
)
180+
} catch (e: SSLException) {
181+
throw e
182+
} catch (e: IOException) {
183+
throwConnectionError(serverUri.toString(), e)
165184
}
166185
}
167186

187+
private fun throwConnectionError(serverUrl: String, e: IOException): Nothing {
188+
thisLogger().warn("TLS trust: connectivity failure probing $serverUrl (${e.message})")
189+
throw IOException("Cannot connect to $serverUrl (check network / IDE HTTP proxy): ${e.message}", e)
190+
}
191+
168192
private suspend fun persistAndVerifyAcceptedTrust(
169193
serverUrl: String,
170194
trustedCerts: List<X509Certificate>,
@@ -201,7 +225,13 @@ class DefaultTlsTrustManager(
201225

202226
val finalCerts = (trustedCerts + trustAnchor).distinctBy { it.serialNumber }
203227
val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts)
204-
withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) }
228+
try {
229+
withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) }
230+
} catch (e: SSLException) {
231+
throw e
232+
} catch (e: IOException) {
233+
throwConnectionError(serverUrl, e)
234+
}
205235
thisLogger().info("TLS trust: verified connection to $serverUrl after user acceptance")
206236
return tlsContext
207237
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
* Copyright (c) 2025-2026 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package com.redhat.devtools.gateway.auth.tls
13+
14+
import com.redhat.devtools.gateway.util.IdeHttpProxy
15+
import java.io.ByteArrayOutputStream
16+
import java.io.IOException
17+
import java.io.InputStream
18+
import java.net.Authenticator
19+
import java.net.InetAddress
20+
import java.net.InetSocketAddress
21+
import java.net.Proxy
22+
import java.net.ProxySelector
23+
import java.net.Socket
24+
import java.net.URI
25+
import java.nio.charset.StandardCharsets
26+
import java.util.Base64
27+
import javax.net.ssl.SSLContext
28+
import javax.net.ssl.SSLException
29+
import javax.net.ssl.SSLSocket
30+
31+
object TlsConnectionProbe {
32+
33+
private const val DEFAULT_HTTPS_PORT = 443
34+
private const val TIMEOUT_MS = 30_000
35+
private const val MAX_LINE_BYTES = 8 * 1024
36+
internal const val MAX_HEADER_LINES = 100
37+
38+
fun connect(
39+
serverUri: URI,
40+
sslContext: SSLContext,
41+
proxySelector: ProxySelector = IdeHttpProxy.proxySelector(),
42+
) {
43+
val host = serverUri.host
44+
?: throw IOException("TLS probe URL has no host: $serverUri")
45+
val port = if (serverUri.port != -1) {
46+
serverUri.port
47+
} else {
48+
DEFAULT_HTTPS_PORT
49+
}
50+
val selectUri = URI("https", null, host, port, null, null, null)
51+
val proxies = proxySelector.select(selectUri)
52+
.ifEmpty { listOf(Proxy.NO_PROXY) }
53+
54+
var lastException: IOException? = null
55+
val connected = proxies.any { proxy ->
56+
try {
57+
connectViaProxy(host, port, sslContext, proxy)
58+
true
59+
} catch (e: SSLException) {
60+
// Handshake / TLS failures must surface for trust capture; do not try another proxy.
61+
throw e
62+
} catch (e: IOException) {
63+
lastException = e
64+
false
65+
}
66+
}
67+
if (!connected) {
68+
throw IOException("TLS probe failed for $host:$port", lastException)
69+
}
70+
}
71+
72+
private fun connectViaProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
73+
when (proxy.type()) {
74+
Proxy.Type.HTTP -> connectViaHttpProxy(host, port, sslContext, proxy)
75+
Proxy.Type.SOCKS -> connectViaSocksOrDirect(host, port, sslContext, proxy)
76+
else -> connectViaSocksOrDirect(host, port, sslContext, Proxy.NO_PROXY)
77+
}
78+
}
79+
80+
private fun connectViaHttpProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
81+
val proxyAddress = proxy.address() as? InetSocketAddress
82+
?: throw IOException("HTTP proxy has no InetSocketAddress")
83+
openTunneledSocket(host, port, proxyAddress, proxyAuthorization = null).use { plain ->
84+
val status = readConnectStatus(plain.getInputStream())
85+
if (status == 200) {
86+
handshakeOver(plain, host, port, sslContext)
87+
return
88+
}
89+
if (status != 407) {
90+
throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status")
91+
}
92+
}
93+
94+
val authHeader = basicProxyAuthorization(proxyAddress, host, port)
95+
?: throw IOException("HTTP proxy $proxyAddress requires authentication (407)")
96+
openTunneledSocket(host, port, proxyAddress, authHeader).use { plain ->
97+
val status = readConnectStatus(plain.getInputStream())
98+
if (status != 200) {
99+
throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status")
100+
}
101+
handshakeOver(plain, host, port, sslContext)
102+
}
103+
}
104+
105+
private fun openTunneledSocket(
106+
host: String,
107+
port: Int,
108+
proxyAddress: InetSocketAddress,
109+
proxyAuthorization: String?,
110+
): Socket {
111+
val socket = Socket()
112+
socket.soTimeout = TIMEOUT_MS
113+
socket.connect(InetSocketAddress(proxyAddress.hostString, proxyAddress.port), TIMEOUT_MS)
114+
try {
115+
writeConnectRequest(socket, host, port, proxyAuthorization)
116+
return socket
117+
} catch (e: IOException) {
118+
socket.close()
119+
throw e
120+
}
121+
}
122+
123+
private fun connectViaSocksOrDirect(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
124+
Socket(proxy).use { plain ->
125+
plain.soTimeout = TIMEOUT_MS
126+
plain.connect(InetSocketAddress(host, port), TIMEOUT_MS)
127+
handshakeOver(plain, host, port, sslContext)
128+
}
129+
}
130+
131+
private fun writeConnectRequest(plain: Socket, host: String, port: Int, proxyAuthorization: String?) {
132+
val request = buildString {
133+
append("CONNECT $host:$port HTTP/1.1\r\n")
134+
append("Host: $host:$port\r\n")
135+
if (proxyAuthorization != null) {
136+
append("Proxy-Authorization: $proxyAuthorization\r\n")
137+
}
138+
append("\r\n")
139+
}
140+
plain.getOutputStream().write(request.toByteArray(StandardCharsets.US_ASCII))
141+
plain.getOutputStream().flush()
142+
}
143+
144+
/**
145+
* Reads the CONNECT response status and headers one byte at a time so we do not
146+
* buffer TLS handshake bytes that follow a successful tunnel.
147+
*/
148+
private fun readConnectStatus(input: InputStream): Int {
149+
val statusLine = readAsciiLine(input)
150+
?: throw IOException("HTTP CONNECT closed with no response")
151+
val status = statusLine.split(' ').getOrNull(1)?.toIntOrNull()
152+
?: throw IOException("Malformed CONNECT response: $statusLine")
153+
repeat(MAX_HEADER_LINES) {
154+
val line = readAsciiLine(input)
155+
if (line == null) throw IOException("CONNECT response closed before header terminator")
156+
if (line.isEmpty()) return status
157+
}
158+
throw IOException("CONNECT response headers exceed limit or are unterminated")
159+
}
160+
161+
private fun readAsciiLine(input: InputStream): String? {
162+
val buffer = ByteArrayOutputStream()
163+
while (true) {
164+
val b = input.read()
165+
if (b == -1) {
166+
return if (buffer.size() == 0) null else buffer.toString(StandardCharsets.US_ASCII)
167+
}
168+
if (b == '\n'.code) {
169+
val bytes = buffer.toByteArray()
170+
val end = if (bytes.isNotEmpty() && bytes.last() == '\r'.code.toByte()) bytes.size - 1 else bytes.size
171+
return String(bytes, 0, end, StandardCharsets.US_ASCII)
172+
}
173+
if (buffer.size() >= MAX_LINE_BYTES) {
174+
throw IOException("CONNECT response line exceeds $MAX_LINE_BYTES bytes")
175+
}
176+
buffer.write(b)
177+
}
178+
}
179+
180+
private fun basicProxyAuthorization(proxyAddress: InetSocketAddress, host: String, port: Int): String? {
181+
// IDE ProxySelectors often return unresolved addresses (address == null).
182+
val proxyInetAddress = proxyAddress.address
183+
?: runCatching { InetAddress.getByName(proxyAddress.hostString) }.getOrNull()
184+
val auth = Authenticator.requestPasswordAuthentication(
185+
proxyAddress.hostString,
186+
proxyInetAddress,
187+
proxyAddress.port,
188+
"https",
189+
"",
190+
"Basic",
191+
URI("https", null, host, port, null, null, null).toURL(),
192+
Authenticator.RequestorType.PROXY,
193+
) ?: return null
194+
val password = auth.password
195+
return try {
196+
val token = Base64.getEncoder().encodeToString(
197+
"${auth.userName}:${String(password)}".toByteArray(StandardCharsets.ISO_8859_1)
198+
)
199+
"Basic $token"
200+
} finally {
201+
password.fill('\u0000')
202+
}
203+
}
204+
205+
private fun handshakeOver(plain: Socket, host: String, port: Int, sslContext: SSLContext) {
206+
val sslSocket = sslContext.socketFactory.createSocket(plain, host, port, true) as SSLSocket
207+
sslSocket.soTimeout = TIMEOUT_MS
208+
sslSocket.sslParameters = sslSocket.sslParameters.apply {
209+
endpointIdentificationAlgorithm = "HTTPS"
210+
}
211+
sslSocket.use { it.startHandshake() }
212+
}
213+
}

src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ object IdeHttpProxy {
4040
}
4141

4242
fun configure(builder: OkHttpClient.Builder): OkHttpClient.Builder =
43-
configure(builder, ideProxySelector())
43+
configure(builder, proxySelector())
4444

4545
fun configure(builder: OkHttpClient.Builder, selector: ProxySelector): OkHttpClient.Builder =
4646
builder
@@ -63,11 +63,11 @@ object IdeHttpProxy {
6363
* test scenarios that require a custom proxy configuration.
6464
*
6565
* @param builder the HTTP client builder to configure
66-
* @param proxySelector optional proxy selector; defaults to the IDE proxy selector from [ideProxySelector]
66+
* @param proxySelector optional proxy selector; defaults to the IDE proxy selector from [proxySelector]
6767
*/
6868
fun configure(
6969
builder: HttpClient.Builder,
70-
proxySelector: ProxySelector = ideProxySelector(),
70+
proxySelector: ProxySelector = this.proxySelector(),
7171
): HttpClient.Builder {
7272
runCatching { JdkProxyProvider.ensureDefault() }
7373
.onFailure { thisLogger().warn("Failed to ensure default JDK proxy provider", it) }
@@ -104,7 +104,12 @@ object IdeHttpProxy {
104104
}
105105
}
106106

107-
private fun ideProxySelector(): ProxySelector =
107+
/**
108+
* Returns the IDE-compatible [ProxySelector] from [JdkProxyProvider],
109+
* falling back to the JVM default selector or a no-proxy selector when
110+
* unavailable.
111+
*/
112+
fun proxySelector(): ProxySelector =
108113
runCatching {
109114
JdkProxyProvider.ensureDefault()
110115
JdkProxyProvider.getInstance().proxySelector

0 commit comments

Comments
 (0)