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 - } - } -}