From 357a317ab839cb280b19251b1c5a565660abbeb5 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 20 Dec 2025 00:32:55 +0200 Subject: [PATCH] fix: mTLS certificate refresh A mechanism to intercept expired certificate errors was introduced in previous releases. Upon intercept the a command was spawend to refresh the certs and then the request was retried again. Unfortunately the interceptor mechanism had a big drawback, it only acts on an invalid response from the server. Most of the times the SSL handshake exceptions are raised much earlier, when the connection to the server is established. In this place the interceptor does not work. A different approach was instead used. All http calls are now wrapped in an executor that is able to retry the request if there is an exception in any of the request lifecycle. --- CHANGELOG.md | 4 + gradle.properties | 2 +- .../com/coder/toolbox/sdk/CoderRestClient.kt | 81 +++++++++++++++---- .../CertificateRefreshInterceptor.kt | 53 ------------ 4 files changed, 72 insertions(+), 68 deletions(-) delete mode 100644 src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index de79e05..3e6e67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- mTLS certificate refresh and http request retrying logic + ## 0.8.1 - 2025-12-11 ### Changed diff --git a/gradle.properties b/gradle.properties index d9ce8bf..041b11c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.8.1 +version=0.8.2 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index b44352d..d96e82a 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,7 +7,6 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException -import com.coder.toolbox.sdk.interceptors.CertificateRefreshInterceptor import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse @@ -23,7 +22,10 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspaceTransition import com.coder.toolbox.util.ReloadableTlsContext import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.zeroturnaround.exec.ProcessExecutor import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory @@ -72,8 +74,6 @@ open class CoderRestClient( throw IllegalStateException("Token is required for $url deployment") } add(Interceptors.tokenAuth(token)) - } else if (context.settingsStore.requiresMTlsAuth && context.settingsStore.tls.certRefreshCommand?.isNotBlank() == true) { - add(CertificateRefreshInterceptor(context, tlsContext)) } add((Interceptors.userAgent(pluginVersion))) add(Interceptors.externalHeaders(context, url)) @@ -114,7 +114,7 @@ open class CoderRestClient( * @throws [APIResponseException]. */ internal suspend fun me(): User { - val userResponse = retroRestClient.me() + val userResponse = callWithRetry { retroRestClient.me() } if (!userResponse.isSuccessful) { throw APIResponseException( "initializeSession", @@ -133,7 +133,7 @@ open class CoderRestClient( * Retrieves the visual dashboard configuration. */ internal suspend fun appearance(): Appearance { - val appearanceResponse = retroRestClient.appearance() + val appearanceResponse = callWithRetry { retroRestClient.appearance() } if (!appearanceResponse.isSuccessful) { throw APIResponseException( "initializeSession", @@ -153,7 +153,7 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun workspaces(): List { - val workspacesResponse = retroRestClient.workspaces("owner:me") + val workspacesResponse = callWithRetry { retroRestClient.workspaces("owner:me") } if (!workspacesResponse.isSuccessful) { throw APIResponseException( "retrieve workspaces", @@ -173,7 +173,7 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun workspace(workspaceID: UUID): Workspace { - val workspaceResponse = retroRestClient.workspace(workspaceID) + val workspaceResponse = callWithRetry { retroRestClient.workspace(workspaceID) } if (!workspaceResponse.isSuccessful) { throw APIResponseException( "retrieve workspace", @@ -196,8 +196,9 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun resources(workspace: Workspace): List { - val resourcesResponse = + val resourcesResponse = callWithRetry { retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID) + } if (!resourcesResponse.isSuccessful) { throw APIResponseException( "retrieve resources for ${workspace.name}", @@ -213,7 +214,7 @@ open class CoderRestClient( } suspend fun buildInfo(): BuildInfo { - val buildInfoResponse = retroRestClient.buildInfo() + val buildInfoResponse = callWithRetry { retroRestClient.buildInfo() } if (!buildInfoResponse.isSuccessful) { throw APIResponseException( "retrieve build information", @@ -232,7 +233,7 @@ open class CoderRestClient( * @throws [APIResponseException]. */ private suspend fun template(templateID: UUID): Template { - val templateResponse = retroRestClient.template(templateID) + val templateResponse = callWithRetry { retroRestClient.template(templateID) } if (!templateResponse.isSuccessful) { throw APIResponseException( "retrieve template with ID $templateID", @@ -258,7 +259,7 @@ open class CoderRestClient( null, WorkspaceBuildReason.JETBRAINS_CONNECTION ) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) + val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) } if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException( "start workspace ${workspace.name}", @@ -277,7 +278,7 @@ open class CoderRestClient( */ suspend fun stopWorkspace(workspace: Workspace): WorkspaceBuild { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) + val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) } if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException( "stop workspace ${workspace.name}", @@ -297,7 +298,7 @@ open class CoderRestClient( */ suspend fun removeWorkspace(workspace: Workspace) { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) + val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) } if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException( "delete workspace ${workspace.name}", @@ -322,7 +323,7 @@ open class CoderRestClient( val template = template(workspace.templateID) val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) + val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) } if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException( "update workspace ${workspace.name}", @@ -337,6 +338,58 @@ open class CoderRestClient( } } + /** + * Executes a Retrofit call with a retry mechanism specifically for expired certificates. + */ + private suspend fun callWithRetry(block: suspend () -> Response): Response { + return try { + block() + } catch (e: Exception) { + if (context.settingsStore.requiresMTlsAuth && isCertExpired(e)) { + context.logger.info("Certificate expired detected. Attempting refresh...") + if (refreshCertificates()) { + context.logger.info("Certificates refreshed, retrying the request...") + return block() + } + } + throw e + } + } + + private fun isCertExpired(e: Exception): Boolean { + return (e is javax.net.ssl.SSLHandshakeException || e is javax.net.ssl.SSLPeerUnverifiedException) && + e.message?.contains("certificate_expired", ignoreCase = true) == true + } + + private suspend fun refreshCertificates(): Boolean = withContext(Dispatchers.IO) { + val command = context.settingsStore.readOnly().tls.certRefreshCommand + if (command.isNullOrBlank()) return@withContext false + + return@withContext try { + val result = ProcessExecutor() + .command(command.split(" ").toList()) + .exitValueNormal() + .readOutput(true) + .execute() + + if (result.exitValue == 0) { + context.logger.info("Certificate refresh successful. Reloading TLS and evicting pool.") + tlsContext.reload() + + // This is the "Magic Fix": + // It forces OkHttp to close the broken HTTP/2 connection. + httpClient.connectionPool.evictAll() + return@withContext true + } else { + context.logger.error("Refresh command failed with code ${result.exitValue}") + false + } + } catch (ex: Exception) { + context.logger.error(ex, "Failed to execute refresh command") + false + } + } + fun close() { httpClient.apply { dispatcher.executorService.shutdown() diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt deleted file mode 100644 index 55dae43..0000000 --- a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.coder.toolbox.sdk.interceptors - -import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.util.ReloadableTlsContext -import okhttp3.Interceptor -import okhttp3.Response -import org.zeroturnaround.exec.ProcessExecutor -import javax.net.ssl.SSLHandshakeException -import javax.net.ssl.SSLPeerUnverifiedException - -class CertificateRefreshInterceptor( - private val context: CoderToolboxContext, - private val tlsContext: ReloadableTlsContext -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - try { - return chain.proceed(request) - } catch (e: Exception) { - if ((e is SSLHandshakeException || e is SSLPeerUnverifiedException) && (e.message?.contains("certificate_expired") == true)) { - val command = context.settingsStore.tls.certRefreshCommand - if (command.isNullOrBlank()) { - throw IllegalStateException( - "Certificate expiration interceptor was set but the refresh command was removed in the meantime", - e - ) - } - - context.logger.info("SSL handshake exception encountered: certificates expired. Running certificate refresh command: $command") - try { - val result = ProcessExecutor() - .command(command.split(" ").toList()) - .exitValueNormal() - .readOutput(true) - .execute() - context.logger.info("`$command`: ${result.outputUTF8()}") - - if (result.exitValue == 0) { - context.logger.info("Certificate refresh command executed successfully. Reloading SSL certificates.") - tlsContext.reload() - // Retry the request - return chain.proceed(request) - } else { - context.logger.error("Certificate refresh command failed with exit code ${result.exitValue}") - } - } catch (ex: Exception) { - context.logger.error(ex, "Failed to execute certificate refresh command") - } - } - throw e - } - } -}