Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ import com.redhat.devtools.gateway.util.toServerBaseUrl
import io.kubernetes.client.util.KubeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.URI
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException

class DefaultTlsTrustManager(
private val kubeConfigProvider: suspend () -> List<KubeConfig>,
private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List<X509Certificate>) -> Unit,
private val sessionTrustStore: SessionTlsTrustStore,
private val persistentKeyStore: PersistentKeyStore,
private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsProbe.connect(uri, ctx.sslContext) },
private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsConnectionProbe.connect(uri, ctx.sslContext) },
private val oauthDiscovery: suspend (String, SSLContext) -> List<String> = { apiBaseUrl, sslContext ->
OAuthDiscovery(apiBaseUrl, sslContext).endpointBaseUrls()
}
Expand Down Expand Up @@ -105,6 +107,10 @@ class DefaultTlsTrustManager(
} catch (e: SSLHandshakeException) {
thisLogger().debug("TLS trust: JVM CAs do not trust $serverUrl (${e.message})")
null
} catch (e: SSLException) {
throw e
} catch (e: IOException) {
throwConnectionError(serverUrl, e)
}
}

Expand Down Expand Up @@ -134,6 +140,10 @@ class DefaultTlsTrustManager(
"TLS trust: handshake failed with known certificate(s) for $serverUrl; will prompt (${e.message})"
)
null
} catch (e: SSLException) {
throw e
} catch (e: IOException) {
throwConnectionError(serverUrl, e)
}
}

Expand All @@ -153,18 +163,31 @@ class DefaultTlsTrustManager(
null // probe succeeded without throwing — no cert info, caller logs unexpected success
} catch (e: SSLHandshakeException) {
val chain = (captureContext.trustManager as? CapturingTrustManager)
?.serverCertificateChain?.toList() ?: throw e
?.serverCertificateChain?.toList()
?: throw e

val trustAnchor = chain.first()
CapturedCertInfo(
problem = if (trustedCerts.isEmpty()) TlsTrustProblem.UNTRUSTED_CERTIFICATE
else TlsTrustProblem.CERTIFICATE_CHANGED,
problem = if (trustedCerts.isEmpty()) {
TlsTrustProblem.UNTRUSTED_CERTIFICATE
} else {
TlsTrustProblem.CERTIFICATE_CHANGED
},
chain = chain,
trustAnchor = trustAnchor,
)
} catch (e: SSLException) {
throw e
} catch (e: IOException) {
throwConnectionError(serverUri.toString(), e)
}
}

private fun throwConnectionError(serverUrl: String, e: IOException): Nothing {
thisLogger().warn("TLS trust: connectivity failure probing $serverUrl (${e.message})")
throw IOException("Cannot connect to $serverUrl (check network / IDE HTTP proxy): ${e.message}", e)
}

private suspend fun persistAndVerifyAcceptedTrust(
serverUrl: String,
trustedCerts: List<X509Certificate>,
Expand Down Expand Up @@ -201,7 +224,13 @@ class DefaultTlsTrustManager(

val finalCerts = (trustedCerts + trustAnchor).distinctBy { it.serialNumber }
val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts)
withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) }
try {
withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) }
} catch (e: SSLException) {
throw e
} catch (e: IOException) {
throwConnectionError(serverUrl, e)
}
thisLogger().info("TLS trust: verified connection to $serverUrl after user acceptance")
return tlsContext
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2025-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package com.redhat.devtools.gateway.auth.tls

import com.redhat.devtools.gateway.util.IdeHttpProxy
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.Authenticator
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.Socket
import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLSocket

object TlsConnectionProbe {

private const val DEFAULT_HTTPS_PORT = 443
private const val TIMEOUT_MS = 30_000

fun connect(
serverUri: URI,
sslContext: SSLContext,
proxySelector: ProxySelector = IdeHttpProxy.proxySelector(),
) {
val host = serverUri.host
?: throw IOException("TLS probe URL has no host: $serverUri")
val port = if (serverUri.port != -1) {
serverUri.port
} else {
DEFAULT_HTTPS_PORT
}
val selectUri = URI("https", null, host, port, null, null, null)
val proxies = proxySelector.select(selectUri)
.ifEmpty { listOf(Proxy.NO_PROXY) }

var lastException: IOException? = null
val connected = proxies.any { proxy ->
try {
connectViaProxy(host, port, sslContext, proxy)
true
} catch (e: SSLException) {
// Handshake / TLS failures must surface for trust capture; do not try another proxy.
throw e
} catch (e: IOException) {
lastException = e
false
}
}
if (!connected) {
throw IOException("TLS probe failed for $host:$port", lastException)
}
}

private fun connectViaProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
when (proxy.type()) {
Proxy.Type.HTTP -> connectViaHttpProxy(host, port, sslContext, proxy)
Proxy.Type.SOCKS -> connectViaSocksOrDirect(host, port, sslContext, proxy)
else -> connectViaSocksOrDirect(host, port, sslContext, Proxy.NO_PROXY)
}
}

private fun connectViaHttpProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
val proxyAddress = proxy.address() as? InetSocketAddress
?: throw IOException("HTTP proxy has no InetSocketAddress")
openTunneledSocket(host, port, proxyAddress, proxyAuthorization = null).use { plain ->
val status = readConnectStatus(plain.getInputStream())
if (status == 200) {
handshakeOver(plain, host, port, sslContext)
return
}
if (status != 407) {
throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status")
}
}

val authHeader = basicProxyAuthorization(proxyAddress, host, port)
?: throw IOException("HTTP proxy $proxyAddress requires authentication (407)")
openTunneledSocket(host, port, proxyAddress, authHeader).use { plain ->
val status = readConnectStatus(plain.getInputStream())
if (status != 200) {
throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status")
}
handshakeOver(plain, host, port, sslContext)
}
}

private fun openTunneledSocket(
host: String,
port: Int,
proxyAddress: InetSocketAddress,
proxyAuthorization: String?,
): Socket {
val socket = Socket()
socket.soTimeout = TIMEOUT_MS
socket.connect(InetSocketAddress(proxyAddress.hostString, proxyAddress.port), TIMEOUT_MS)
try {
writeConnectRequest(socket, host, port, proxyAuthorization)
return socket
} catch (e: IOException) {
socket.close()
throw e
}
}

private fun connectViaSocksOrDirect(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) {
Socket(proxy).use { plain ->
plain.soTimeout = TIMEOUT_MS
plain.connect(InetSocketAddress(host, port), TIMEOUT_MS)
handshakeOver(plain, host, port, sslContext)
}
}

private fun writeConnectRequest(plain: Socket, host: String, port: Int, proxyAuthorization: String?) {
val request = buildString {
append("CONNECT $host:$port HTTP/1.1\r\n")
append("Host: $host:$port\r\n")
if (proxyAuthorization != null) {
append("Proxy-Authorization: $proxyAuthorization\r\n")
}
append("\r\n")
}
plain.getOutputStream().write(request.toByteArray(StandardCharsets.US_ASCII))
plain.getOutputStream().flush()
}

/**
* Reads the CONNECT response status and headers one byte at a time so we do not
* buffer TLS handshake bytes that follow a successful tunnel.
*/
private fun readConnectStatus(input: InputStream): Int {
val statusLine = readAsciiLine(input)
?: throw IOException("HTTP CONNECT closed with no response")
val status = statusLine.split(' ').getOrNull(1)?.toIntOrNull()
?: throw IOException("Malformed CONNECT response: $statusLine")
while (true) {
val line = readAsciiLine(input) ?: break
if (line.isEmpty()) break
}
return status
}

private fun readAsciiLine(input: InputStream): String? {
val buffer = ByteArrayOutputStream()
while (true) {
val b = input.read()
if (b == -1) {
return if (buffer.size() == 0) null else buffer.toString(StandardCharsets.US_ASCII)
}
if (b == '\n'.code) {
val bytes = buffer.toByteArray()
val end = if (bytes.isNotEmpty() && bytes.last() == '\r'.code.toByte()) bytes.size - 1 else bytes.size
return String(bytes, 0, end, StandardCharsets.US_ASCII)
}
buffer.write(b)
}
}

private fun basicProxyAuthorization(proxyAddress: InetSocketAddress, host: String, port: Int): String? {
// IDE ProxySelectors often return unresolved addresses (address == null).
val proxyInetAddress = proxyAddress.address
?: runCatching { InetAddress.getByName(proxyAddress.hostString) }.getOrNull()
val auth = Authenticator.requestPasswordAuthentication(
proxyAddress.hostString,
proxyInetAddress,
proxyAddress.port,
"https",
"",
"Basic",
URI("https", null, host, port, null, null, null).toURL(),
Authenticator.RequestorType.PROXY,
) ?: return null
val password = auth.password
return try {
val token = Base64.getEncoder().encodeToString(
"${auth.userName}:${String(password)}".toByteArray(StandardCharsets.ISO_8859_1)
)
"Basic $token"
} finally {
password.fill('\u0000')
}
}

private fun handshakeOver(plain: Socket, host: String, port: Int, sslContext: SSLContext) {
val sslSocket = sslContext.socketFactory.createSocket(plain, host, port, true) as SSLSocket
sslSocket.soTimeout = TIMEOUT_MS
sslSocket.use { it.startHandshake() }
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ object IdeHttpProxy {
}

fun configure(builder: OkHttpClient.Builder): OkHttpClient.Builder =
configure(builder, ideProxySelector())
configure(builder, proxySelector())

fun configure(builder: OkHttpClient.Builder, selector: ProxySelector): OkHttpClient.Builder =
builder
Expand All @@ -63,11 +63,11 @@ object IdeHttpProxy {
* test scenarios that require a custom proxy configuration.
*
* @param builder the HTTP client builder to configure
* @param proxySelector optional proxy selector; defaults to the IDE proxy selector from [ideProxySelector]
* @param proxySelector optional proxy selector; defaults to the IDE proxy selector from [proxySelector]
*/
fun configure(
builder: HttpClient.Builder,
proxySelector: ProxySelector = ideProxySelector(),
proxySelector: ProxySelector = this.proxySelector(),
): HttpClient.Builder {
runCatching { JdkProxyProvider.ensureDefault() }
.onFailure { thisLogger().warn("Failed to ensure default JDK proxy provider", it) }
Expand Down Expand Up @@ -104,7 +104,12 @@ object IdeHttpProxy {
}
}

private fun ideProxySelector(): ProxySelector =
/**
* Returns the IDE-compatible [ProxySelector] from [JdkProxyProvider],
* falling back to the JVM default selector or a no-proxy selector when
* unavailable.
*/
fun proxySelector(): ProxySelector =
runCatching {
JdkProxyProvider.ensureDefault()
JdkProxyProvider.getInstance().proxySelector
Expand Down
Loading
Loading