Skip to content

Commit f3f7133

Browse files
adietishcursoragent
andcommitted
fix: route TLS trust probe through IDE HTTP proxy (CRW-12333)
TlsProbe 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 f3f7133

9 files changed

Lines changed: 632 additions & 39 deletions

File tree

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

Lines changed: 34 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,31 @@ 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+
?: throw e
157168

158169
val trustAnchor = chain.first()
159170
CapturedCertInfo(
160-
problem = if (trustedCerts.isEmpty()) TlsTrustProblem.UNTRUSTED_CERTIFICATE
161-
else TlsTrustProblem.CERTIFICATE_CHANGED,
171+
problem = if (trustedCerts.isEmpty()) {
172+
TlsTrustProblem.UNTRUSTED_CERTIFICATE
173+
} else {
174+
TlsTrustProblem.CERTIFICATE_CHANGED
175+
},
162176
chain = chain,
163177
trustAnchor = trustAnchor,
164178
)
179+
} catch (e: SSLException) {
180+
throw e
181+
} catch (e: IOException) {
182+
throwConnectionError(serverUri.toString(), e)
165183
}
166184
}
167185

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

202225
val finalCerts = (trustedCerts + trustAnchor).distinctBy { it.serialNumber }
203226
val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts)
204-
withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) }
227+
try {
228+
withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) }
229+
} catch (e: SSLException) {
230+
throw e
231+
} catch (e: IOException) {
232+
throwConnectionError(serverUrl, e)
233+
}
205234
thisLogger().info("TLS trust: verified connection to $serverUrl after user acceptance")
206235
return tlsContext
207236
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
36+
fun connect(
37+
serverUri: URI,
38+
sslContext: SSLContext,
39+
proxySelector: ProxySelector = IdeHttpProxy.proxySelector(),
40+
) {
41+
val host = serverUri.host ?: throw IOException("TLS probe URL has no host: $serverUri")
42+
val port = if (serverUri.port != -1) serverUri.port else DEFAULT_HTTPS_PORT
43+
val selectUri = URI("https", null, host, port, null, null, null)
44+
val proxies = proxySelector.select(selectUri).ifEmpty { listOf(Proxy.NO_PROXY) }
45+
46+
var lastException: IOException? = null
47+
for (proxy in proxies) {
48+
try {
49+
connectViaProxy(host, port, sslContext, proxy)
50+
return
51+
} catch (e: SSLException) {
52+
// Handshake / TLS failures must surface for trust capture; do not try another proxy.
53+
throw e
54+
} catch (e: IOException) {
55+
lastException = e
56+
}
57+
}
58+
throw IOException("TLS probe failed for $host:$port", lastException)
59+
}
60+
61+
private fun connectViaProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
62+
when (proxy.type()) {
63+
Proxy.Type.HTTP -> connectViaHttpProxy(host, port, sslContext, proxy)
64+
Proxy.Type.SOCKS -> connectViaSocksOrDirect(host, port, sslContext, proxy)
65+
else -> connectViaSocksOrDirect(host, port, sslContext, Proxy.NO_PROXY)
66+
}
67+
}
68+
69+
private fun connectViaHttpProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
70+
val proxyAddress = proxy.address() as? InetSocketAddress
71+
?: throw IOException("HTTP proxy has no InetSocketAddress")
72+
openTunneledSocket(host, port, proxyAddress, proxyAuthorization = null).use { plain ->
73+
val status = readConnectStatus(plain.getInputStream())
74+
if (status == 200) {
75+
handshakeOver(plain, host, port, sslContext)
76+
return
77+
}
78+
if (status != 407) {
79+
throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status")
80+
}
81+
}
82+
83+
val authHeader = basicProxyAuthorization(proxyAddress, host, port)
84+
?: throw IOException("HTTP proxy $proxyAddress requires authentication (407)")
85+
openTunneledSocket(host, port, proxyAddress, authHeader).use { plain ->
86+
val status = readConnectStatus(plain.getInputStream())
87+
if (status != 200) {
88+
throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status")
89+
}
90+
handshakeOver(plain, host, port, sslContext)
91+
}
92+
}
93+
94+
private fun openTunneledSocket(
95+
host: String,
96+
port: Int,
97+
proxyAddress: InetSocketAddress,
98+
proxyAuthorization: String?,
99+
): Socket {
100+
val socket = Socket()
101+
socket.soTimeout = TIMEOUT_MS
102+
socket.connect(InetSocketAddress(proxyAddress.hostString, proxyAddress.port), TIMEOUT_MS)
103+
try {
104+
writeConnectRequest(socket, host, port, proxyAuthorization)
105+
return socket
106+
} catch (e: IOException) {
107+
socket.close()
108+
throw e
109+
}
110+
}
111+
112+
private fun connectViaSocksOrDirect(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
113+
Socket(proxy).use { plain ->
114+
plain.soTimeout = TIMEOUT_MS
115+
plain.connect(InetSocketAddress(host, port), TIMEOUT_MS)
116+
handshakeOver(plain, host, port, sslContext)
117+
}
118+
}
119+
120+
private fun writeConnectRequest(plain: Socket, host: String, port: Int, proxyAuthorization: String?) {
121+
val request = buildString {
122+
append("CONNECT $host:$port HTTP/1.1\r\n")
123+
append("Host: $host:$port\r\n")
124+
if (proxyAuthorization != null) {
125+
append("Proxy-Authorization: $proxyAuthorization\r\n")
126+
}
127+
append("\r\n")
128+
}
129+
plain.getOutputStream().write(request.toByteArray(StandardCharsets.US_ASCII))
130+
plain.getOutputStream().flush()
131+
}
132+
133+
/**
134+
* Reads the CONNECT response status and headers one byte at a time so we do not
135+
* buffer TLS handshake bytes that follow a successful tunnel.
136+
*/
137+
private fun readConnectStatus(input: InputStream): Int {
138+
val statusLine = readAsciiLine(input) ?: throw IOException("HTTP CONNECT closed with no response")
139+
val status = statusLine.split(' ').getOrNull(1)?.toIntOrNull()
140+
?: throw IOException("Malformed CONNECT response: $statusLine")
141+
while (true) {
142+
val line = readAsciiLine(input) ?: break
143+
if (line.isEmpty()) break
144+
}
145+
return status
146+
}
147+
148+
private fun readAsciiLine(input: InputStream): String? {
149+
val buffer = ByteArrayOutputStream()
150+
while (true) {
151+
val b = input.read()
152+
if (b == -1) {
153+
return if (buffer.size() == 0) null else buffer.toString(StandardCharsets.US_ASCII)
154+
}
155+
if (b == '\n'.code) {
156+
val bytes = buffer.toByteArray()
157+
val end = if (bytes.isNotEmpty() && bytes.last() == '\r'.code.toByte()) bytes.size - 1 else bytes.size
158+
return String(bytes, 0, end, StandardCharsets.US_ASCII)
159+
}
160+
buffer.write(b)
161+
}
162+
}
163+
164+
private fun basicProxyAuthorization(proxyAddress: InetSocketAddress, host: String, port: Int): String? {
165+
// IDE ProxySelectors often return unresolved addresses (address == null).
166+
val proxyInetAddress = proxyAddress.address
167+
?: runCatching { InetAddress.getByName(proxyAddress.hostString) }.getOrNull()
168+
val auth = Authenticator.requestPasswordAuthentication(
169+
proxyAddress.hostString,
170+
proxyInetAddress,
171+
proxyAddress.port,
172+
"https",
173+
"",
174+
"Basic",
175+
URI("https", null, host, port, null, null, null).toURL(),
176+
Authenticator.RequestorType.PROXY,
177+
) ?: return null
178+
val password = auth.password
179+
return try {
180+
val token = Base64.getEncoder().encodeToString(
181+
"${auth.userName}:${String(password)}".toByteArray(StandardCharsets.ISO_8859_1)
182+
)
183+
"Basic $token"
184+
} finally {
185+
password.fill('\u0000')
186+
}
187+
}
188+
189+
private fun handshakeOver(plain: Socket, host: String, port: Int, sslContext: SSLContext) {
190+
val sslSocket = sslContext.socketFactory.createSocket(plain, host, port, true) as SSLSocket
191+
sslSocket.soTimeout = TIMEOUT_MS
192+
sslSocket.use { it.startHandshake() }
193+
}
194+
}

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)