From ce62cefc357454ef5aa71822febab4ceadfbae79 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 10 Aug 2026 15:09:15 +0200 Subject: [PATCH] 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 Co-authored-by: Cursor --- .../auth/tls/DefaultTlsTrustManager.kt | 39 ++- .../gateway/auth/tls/TlsConnectionProbe.kt | 204 ++++++++++++ .../devtools/gateway/auth/tls/TlsProbe.kt | 30 -- .../devtools/gateway/util/IdeHttpProxy.kt | 13 +- .../tls/DefaultTlsTrustManagerTrustTest.kt | 53 ++++ .../auth/tls/TlsConnectionProbeTest.kt | 291 ++++++++++++++++++ src/test/resources/tls/README.md | 4 + src/test/resources/tls/server-cert.pem | 19 ++ src/test/resources/tls/server-key.pem | 28 ++ 9 files changed, 642 insertions(+), 39 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt delete mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTest.kt create mode 100644 src/test/resources/tls/README.md create mode 100644 src/test/resources/tls/server-cert.pem create mode 100644 src/test/resources/tls/server-key.pem diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 36ef905e..14611d8f 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -19,9 +19,11 @@ 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( @@ -29,7 +31,7 @@ class DefaultTlsTrustManager( private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List) -> 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 = { apiBaseUrl, sslContext -> OAuthDiscovery(apiBaseUrl, sslContext).endpointBaseUrls() } @@ -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) } } @@ -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) } } @@ -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, @@ -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 } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt new file mode 100644 index 00000000..b42dde73 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt @@ -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() } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt deleted file mode 100644 index 25515ef8..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 java.net.URI -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocket - -object TlsProbe { - - private const val DEFAULT_HTTPS_PORT = 443 - - fun connect(serverUri: URI, sslContext: SSLContext) { - val socketFactory = sslContext.socketFactory - val port = if (serverUri.port != -1) serverUri.port else DEFAULT_HTTPS_PORT - - (socketFactory.createSocket(serverUri.host, port) as SSLSocket).use { socket -> - socket.startHandshake() - } - } -} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt index f8c8b819..e9b0bddd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt @@ -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 @@ -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) } @@ -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 diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt index 787cb46d..f2312e1b 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt @@ -23,6 +23,9 @@ import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.successT import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException import javax.net.ssl.X509TrustManager class DefaultTlsTrustManagerTrustTest { @@ -289,4 +292,54 @@ class DefaultTlsTrustManagerTrustTest { .containsExactly(kubeCa.serialNumber) } } + + @Test + fun `#createTlsContext fails clearly on ConnectException without prompting`() { + runBlocking { + var prompted = false + val manager = createManager( + tlsProbe = { _, _ -> throw ConnectException("connection timed out") }, + ) + + val error = runCatching { + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + ) + }.exceptionOrNull() + + assertThat(prompted).isFalse() + assertThat(error).isInstanceOf(IOException::class.java) + assertThat(error!!.message).contains("Cannot connect").contains("IDE HTTP proxy") + assertThat(error.cause).isInstanceOf(ConnectException::class.java) + } + } + + @Test + fun `#createTlsContext fails clearly on SocketTimeoutException without prompting`() { + runBlocking { + var prompted = false + val manager = createManager( + tlsProbe = { _, _ -> throw SocketTimeoutException("read timed out") }, + ) + + val error = runCatching { + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + ) + }.exceptionOrNull() + + assertThat(prompted).isFalse() + assertThat(error).isInstanceOf(IOException::class.java) + assertThat(error!!.message).contains("Cannot connect").contains("IDE HTTP proxy") + assertThat(error.cause).isInstanceOf(SocketTimeoutException::class.java) + } + } } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTest.kt new file mode 100644 index 00000000..0e5523c0 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTest.kt @@ -0,0 +1,291 @@ +/* + * Copyright (c) 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 org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.ProxySelector +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketAddress +import java.net.URI +import java.security.KeyStore +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.SSLSocket + +class TlsConnectionProbeTest { + + private val executor = Executors.newCachedThreadPool() + private val sockets = mutableListOf() + private val tasks = mutableListOf>() + private var originalAuthenticator: Authenticator? = null + + @BeforeEach + fun setUp() { + originalAuthenticator = Authenticator.getDefault() + } + + @AfterEach + fun tearDown() { + tasks.forEach { it.cancel(true) } + sockets.asReversed().forEach { runCatching { it.close() } } + executor.shutdownNow() + Authenticator.setDefault(originalAuthenticator) + } + + @Test + fun `#connect succeeds over DIRECT`() { + val tlsServer = startTlsServer() + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect(uri, SslContextFactory.insecure().sslContext, directSelector()) + }.doesNotThrowAnyException() + } + + @Test + fun `#connect succeeds via HTTP CONNECT proxy`() { + val tlsServer = startTlsServer() + val proxy = startConnectProxy(tlsServer.localPort, requireAuth = false) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect(uri, SslContextFactory.insecure().sslContext, httpProxySelector(proxy.localPort)) + }.doesNotThrowAnyException() + } + + @Test + fun `#connect propagates SSLHandshakeException after retrying a failed proxy`() { + val tlsServer = startTlsServer() + // Unreachable HTTP proxy first (IOException → try next); then DIRECT to self-signed server + // with JVM trust only → SSLHandshakeException must propagate. + val deadProxyPort = ServerSocket(0).use { it.localPort } + val selector = object : ProxySelector() { + override fun select(uri: URI): List = listOf( + Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", deadProxyPort)), + Proxy.NO_PROXY, + ) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} + } + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + val error = runCatching { + TlsConnectionProbe.connect(uri, SslContextFactory.fromSystemTrust().sslContext, selector) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(javax.net.ssl.SSLHandshakeException::class.java) + } + + @Test + fun `#connect does not try another proxy after SSLHandshakeException`() { + val handshakeAttempts = AtomicInteger(0) + val tlsServer = startTlsServer(onHandshake = { handshakeAttempts.incrementAndGet() }) + val proxy = startConnectProxy(tlsServer.localPort, requireAuth = false) + // After CONNECT handshake fails, NO_PROXY must not be attempted (would be a 2nd handshake). + val selector = object : ProxySelector() { + override fun select(uri: URI): List = listOf( + Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", proxy.localPort)), + Proxy.NO_PROXY, + ) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} + } + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + val error = runCatching { + TlsConnectionProbe.connect(uri, SslContextFactory.fromSystemTrust().sslContext, selector) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(javax.net.ssl.SSLHandshakeException::class.java) + assertThat(handshakeAttempts.get()).isEqualTo(1) + } + + @Test + fun `#connect retries CONNECT with Basic proxy auth after 407`() { + val tlsServer = startTlsServer() + val unauthorizedAttempts = AtomicInteger(0) + val proxy = startConnectProxy(tlsServer.localPort, requireAuth = true, unauthorizedAttempts) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication = + PasswordAuthentication("proxy-user", "proxy-pass".toCharArray()) + }) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect(uri, SslContextFactory.insecure().sslContext, httpProxySelector(proxy.localPort)) + }.doesNotThrowAnyException() + assertThat(unauthorizedAttempts.get()).isGreaterThanOrEqualTo(1) + } + + private fun directSelector(): ProxySelector = object : ProxySelector() { + override fun select(uri: URI): List = listOf(Proxy.NO_PROXY) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} + } + + private fun httpProxySelector(proxyPort: Int): ProxySelector = object : ProxySelector() { + override fun select(uri: URI): List = + listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", proxyPort))) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} + } + + private fun startTlsServer(onHandshake: () -> Unit = {}): SSLServerSocket { + val sslContext = serverSslContext() + val server = sslContext.serverSocketFactory.createServerSocket(0) as SSLServerSocket + sockets += server + tasks += executor.submit { + while (!server.isClosed) { + try { + (server.accept() as SSLSocket).use { client -> + client.soTimeout = 5_000 + onHandshake() + client.startHandshake() + client.getInputStream().read() + } + } catch (_: Exception) { + break + } + } + } + return server + } + + private fun startConnectProxy( + targetPort: Int, + requireAuth: Boolean, + unauthorizedAttempts: AtomicInteger = AtomicInteger(0), + ): ServerSocket { + val server = ServerSocket(0) + sockets += server + tasks += executor.submit { + while (!server.isClosed) { + try { + val client = server.accept() + tasks += executor.submit { + handleConnectClient(client, targetPort, requireAuth, unauthorizedAttempts) + } + } catch (_: Exception) { + break + } + } + } + return server + } + + private fun handleConnectClient( + client: Socket, + targetPort: Int, + requireAuth: Boolean, + unauthorizedAttempts: AtomicInteger, + ) { + client.use { proxyClient -> + proxyClient.soTimeout = 5_000 + val request = readHttpHeaders(proxyClient.getInputStream()) + val authorized = request.lines().any { + it.startsWith("Proxy-Authorization: Basic ", ignoreCase = true) + } + if (requireAuth && !authorized) { + unauthorizedAttempts.incrementAndGet() + proxyClient.getOutputStream().write( + "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"test\"\r\n\r\n" + .toByteArray() + ) + proxyClient.getOutputStream().flush() + return + } + if (!request.startsWith("CONNECT ")) { + proxyClient.getOutputStream().write("HTTP/1.1 400 Bad Request\r\n\r\n".toByteArray()) + return + } + Socket("127.0.0.1", targetPort).use { origin -> + proxyClient.getOutputStream().write("HTTP/1.1 200 Connection Established\r\n\r\n".toByteArray()) + proxyClient.getOutputStream().flush() + val up = executor.submit { copy(proxyClient.getInputStream(), origin.getOutputStream()) } + try { + copy(origin.getInputStream(), proxyClient.getOutputStream()) + } finally { + up.cancel(true) + } + } + } + } + + private fun readHttpHeaders(input: InputStream): String { + val bytes = ArrayList() + var state = 0 + while (true) { + val b = input.read() + if (b == -1) break + bytes += b.toByte() + state = when { + state == 0 && b == '\r'.code -> 1 + state == 1 && b == '\n'.code -> 2 + state == 2 && b == '\r'.code -> 3 + state == 3 && b == '\n'.code -> break + else -> 0 + } + } + return String(bytes.toByteArray()) + } + + private fun copy(from: InputStream, to: OutputStream) { + val buffer = ByteArray(8_192) + while (true) { + val n = try { + from.read(buffer) + } catch (_: Exception) { + -1 + } + if (n < 0) break + to.write(buffer, 0, n) + to.flush() + } + } + + private fun serverSslContext(): SSLContext { + val key = PemUtils.parsePrivateKey(SERVER_KEY_PEM) + val cert = PemUtils.parseCertificate(SERVER_CERT_PEM) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setKeyEntry("server", key, CHAR_ARRAY_EMPTY, arrayOf(cert)) + } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(keyStore, CHAR_ARRAY_EMPTY) + } + return SSLContext.getInstance("TLS").apply { + init(kmf.keyManagers, null, null) + } + } + + companion object { + private val CHAR_ARRAY_EMPTY = CharArray(0) + + private val SERVER_KEY_PEM = TlsConnectionProbeTest::class.java.classLoader + .getResourceAsStream("tls/server-key.pem")!!.readBytes().toString(Charsets.UTF_8) + + private val SERVER_CERT_PEM = TlsConnectionProbeTest::class.java.classLoader + .getResourceAsStream("tls/server-cert.pem")!!.readBytes().toString(Charsets.UTF_8) + } +} diff --git a/src/test/resources/tls/README.md b/src/test/resources/tls/README.md new file mode 100644 index 00000000..d76b976e --- /dev/null +++ b/src/test/resources/tls/README.md @@ -0,0 +1,4 @@ +# Test TLS fixtures + +notsecret — `server-key.pem` / `server-cert.pem` are synthetic localhost-only +material for `TlsConnectionProbeTest`. Do not use outside unit tests. diff --git a/src/test/resources/tls/server-cert.pem b/src/test/resources/tls/server-cert.pem new file mode 100644 index 00000000..93ce0482 --- /dev/null +++ b/src/test/resources/tls/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUdQo82+W/6koubex7il3NM0ZzXQwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgxMDEyNTI1OFoXDTM2MDgw +NzEyNTI1OFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAjWty/EDc8yllHzEXsaGH09vFQNqR9sLcRxRCfHUy0Vvs +8JSs4akSUQhvgMosuxKCaSN8tNhT2sYkpBUMBS5822NWSFImbjvH/ELYQpgrEjww +TQskG48ToehcLJGpBtU2CIhjPXYsMulIKYrKPfgcwCIFTWi9uEl8TLeuunaZ0Kzo +GB70jY6d42w/3x7OkUitnb3DLDVIS5DJu9hHkC58981GO/WYI/Y77XLPYvv1wpU+ +EsVRAo1MpO+AUScaSHULzFT4e6CA/mIOV0ayf+kT2dmAu66B3F71QE73iUf6bVKI +gpgqRA+bxA1OEPvpz3wXPzudZiX7frFI/af7U8lfDQIDAQABo1MwUTAdBgNVHQ4E +FgQU2RAgCSdpXi+g3DXOqszh0nxQqpMwHwYDVR0jBBgwFoAU2RAgCSdpXi+g3DXO +qszh0nxQqpMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAaUWh +oPF2PABGMZ2NcIcfJPQdAQMul4p6+p4ChsdabUdzDnBaApyBt8nm/OpYx+wcEPfo +c4Ge5DcuIe+tvamRaP2pt9uLntATtWBhjofw5cJYjZAB5rg8bUEZmuRDOKAawuet +GgsL17o/iLJWEQfKLVZXpOv0mEjXFbdITFQyH18I6e7g27VoqEmZPwjhleOgh5eo +5J1z2cGfh1G3V8Wxp+HYlBIcqUZJpG7tQ3k/CuEVh0gzFrhj/XoQFlLiUaJu33jb +N///vOc+hZ6J/x9fS4bqO6nFCCZIlXgJt64yvC9NAlSvRr+5aIJEeFkauXNoG4ER +3hNLh4Hv0EqH2VdZyw== +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/server-key.pem b/src/test/resources/tls/server-key.pem new file mode 100644 index 00000000..b439e51f --- /dev/null +++ b/src/test/resources/tls/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCNa3L8QNzzKWUf +MRexoYfT28VA2pH2wtxHFEJ8dTLRW+zwlKzhqRJRCG+Ayiy7EoJpI3y02FPaxiSk +FQwFLnzbY1ZIUiZuO8f8QthCmCsSPDBNCyQbjxOh6FwskakG1TYIiGM9diwy6Ugp +iso9+BzAIgVNaL24SXxMt666dpnQrOgYHvSNjp3jbD/fHs6RSK2dvcMsNUhLkMm7 +2EeQLnz3zUY79Zgj9jvtcs9i+/XClT4SxVECjUyk74BRJxpIdQvMVPh7oID+Yg5X +RrJ/6RPZ2YC7roHcXvVATveJR/ptUoiCmCpED5vEDU4Q++nPfBc/O51mJft+sUj9 +p/tTyV8NAgMBAAECggEADrcVhJCt7PzBBu6Gy/bw4+dEWSvlGvYfvE2sCriZ4cBc +9pOh1uv5efEUw86bD5mCHwqQRAkWgKHe14FRJODiIQ9EanYR2vqMJABDRyVU+1Vu +o+BQO4XhwcEkhjFfPdTFsJGbMfQFRAQ+Jq8dvYuprNxdYjakjHPTuUU2Vd9YFfx1 +nDlK3Z2QUf4s89K9ojiTgtGrkw9j1pAN8WMDIbbhGHkUpGzJrZ6smM3UOLRMQfp4 +G3Jef0v7xCBqqnwocvp78QER8kuTHr/9MYHugzOQhyrnlV5FxRkboiByLsM4GlnU +7yDerkqy+igl4Je/WtX+ooLstdI0JYpHU/3KVrggiQKBgQDFxTfOGYU3bFHVQkln +u3GelUzDyhe7/WKeALBjWv7Uqc/rKc4tPv1DzhJvi5HzGA/SMAfq6HZsrBIzn1FW +iVXZT434JMMpZsYnsmHQWx3uyU3LWVBdT21GcZ0w/xFVbEFYFtE0c5MbIugFdczM +XvCwkw4ig4aVZpkIwnhv5XAA/wKBgQC3DtiRlZTnC4Te0AAEkZV5K29H7w4/eYGE +MnK6NdyJq8ioRbsSglp3QPJRVLRHdGLBh+Y+rA2Zng9fp/5ZI7HlMtXBq7lGfJaf +Vh4E3RBHeWEvR6tiELPjl8HoAWn3m+kyr/uZUkyNqU/tmwxHkCC7OCtoL+vh1i7X +tXM64BqT8wKBgGHTEKx4gSWOBdhn5mlSFXxsu0DpWN4bEsm264jpvL0yle7ridll +m98LMqFMN1A4abL9IR14CQPuBT7VomUcn4NTT5UbkhGLjZ6bJLjsKR0xI7LfJdpR +7Gp4zlkrAcbwOk45UxgPxwcYOA5jW38HwySESOyXhF2oFzB3CR3ILqO/AoGAAL+i +yxWdgkRdyrt3BI3D2rb+wYCMwl2w1eWO6owF1tpI+8ctOKonzI0LcKG0CwbC0J/J +pT/23kXzMiTxuVOYCqPmk+Ar5cnko8oqXUK6KlCowRS98qy2z5tzQ4ud71FH5tkp +Yjsf7QRgO0yDBFfmil10b/yiLk496svnKLp54VkCgYAV0Pf9Yz0haixlejEBglrH +R1FzOJThmSmeFXsebrkuFE6+jc3hfUJgbZwb5BWVADb5ibFAV1iajqNLm4XvROdr +1bZjUB1R9BBq2XQUfRvh07c+5f3FIXYi17gDDuhoGPfVD7jAh1G1oht4c50gTxdk +cTWQ2ify80U+V0p9TlLdbQ== +-----END PRIVATE KEY-----