Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixed

- mTLS certificate refresh and http request retrying logic

## 0.8.1 - 2025-12-11

### Changed
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=0.8.1
version=0.8.2
group=com.coder.toolbox
name=coder-toolbox
81 changes: 67 additions & 14 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -153,7 +153,7 @@ open class CoderRestClient(
* @throws [APIResponseException].
*/
suspend fun workspaces(): List<Workspace> {
val workspacesResponse = retroRestClient.workspaces("owner:me")
val workspacesResponse = callWithRetry { retroRestClient.workspaces("owner:me") }
if (!workspacesResponse.isSuccessful) {
throw APIResponseException(
"retrieve workspaces",
Expand All @@ -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",
Expand All @@ -196,8 +196,9 @@ open class CoderRestClient(
* @throws [APIResponseException].
*/
suspend fun resources(workspace: Workspace): List<WorkspaceResource> {
val resourcesResponse =
val resourcesResponse = callWithRetry {
retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID)
}
if (!resourcesResponse.isSuccessful) {
throw APIResponseException(
"retrieve resources for ${workspace.name}",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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}",
Expand All @@ -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}",
Expand All @@ -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}",
Expand All @@ -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}",
Expand All @@ -337,6 +338,58 @@ open class CoderRestClient(
}
}

/**
* Executes a Retrofit call with a retry mechanism specifically for expired certificates.
*/
private suspend fun <T> callWithRetry(block: suspend () -> Response<T>): Response<T> {
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()
Expand Down

This file was deleted.

Loading