Skip to content
Open
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
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