Skip to content

Commit 357a317

Browse files
committed
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.
1 parent f004533 commit 357a317

File tree

4 files changed

+72
-68
lines changed

4 files changed

+72
-68
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- mTLS certificate refresh and http request retrying logic
8+
59
## 0.8.1 - 2025-12-11
610

711
### Changed

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.8.1
1+
version=0.8.2
22
group=com.coder.toolbox
33
name=coder-toolbox

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory
77
import com.coder.toolbox.sdk.convertors.OSConverter
88
import com.coder.toolbox.sdk.convertors.UUIDConverter
99
import com.coder.toolbox.sdk.ex.APIResponseException
10-
import com.coder.toolbox.sdk.interceptors.CertificateRefreshInterceptor
1110
import com.coder.toolbox.sdk.interceptors.Interceptors
1211
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
1312
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
@@ -23,7 +22,10 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceResource
2322
import com.coder.toolbox.sdk.v2.models.WorkspaceTransition
2423
import com.coder.toolbox.util.ReloadableTlsContext
2524
import com.squareup.moshi.Moshi
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.withContext
2627
import okhttp3.OkHttpClient
28+
import org.zeroturnaround.exec.ProcessExecutor
2729
import retrofit2.Response
2830
import retrofit2.Retrofit
2931
import retrofit2.converter.moshi.MoshiConverterFactory
@@ -72,8 +74,6 @@ open class CoderRestClient(
7274
throw IllegalStateException("Token is required for $url deployment")
7375
}
7476
add(Interceptors.tokenAuth(token))
75-
} else if (context.settingsStore.requiresMTlsAuth && context.settingsStore.tls.certRefreshCommand?.isNotBlank() == true) {
76-
add(CertificateRefreshInterceptor(context, tlsContext))
7777
}
7878
add((Interceptors.userAgent(pluginVersion)))
7979
add(Interceptors.externalHeaders(context, url))
@@ -114,7 +114,7 @@ open class CoderRestClient(
114114
* @throws [APIResponseException].
115115
*/
116116
internal suspend fun me(): User {
117-
val userResponse = retroRestClient.me()
117+
val userResponse = callWithRetry { retroRestClient.me() }
118118
if (!userResponse.isSuccessful) {
119119
throw APIResponseException(
120120
"initializeSession",
@@ -133,7 +133,7 @@ open class CoderRestClient(
133133
* Retrieves the visual dashboard configuration.
134134
*/
135135
internal suspend fun appearance(): Appearance {
136-
val appearanceResponse = retroRestClient.appearance()
136+
val appearanceResponse = callWithRetry { retroRestClient.appearance() }
137137
if (!appearanceResponse.isSuccessful) {
138138
throw APIResponseException(
139139
"initializeSession",
@@ -153,7 +153,7 @@ open class CoderRestClient(
153153
* @throws [APIResponseException].
154154
*/
155155
suspend fun workspaces(): List<Workspace> {
156-
val workspacesResponse = retroRestClient.workspaces("owner:me")
156+
val workspacesResponse = callWithRetry { retroRestClient.workspaces("owner:me") }
157157
if (!workspacesResponse.isSuccessful) {
158158
throw APIResponseException(
159159
"retrieve workspaces",
@@ -173,7 +173,7 @@ open class CoderRestClient(
173173
* @throws [APIResponseException].
174174
*/
175175
suspend fun workspace(workspaceID: UUID): Workspace {
176-
val workspaceResponse = retroRestClient.workspace(workspaceID)
176+
val workspaceResponse = callWithRetry { retroRestClient.workspace(workspaceID) }
177177
if (!workspaceResponse.isSuccessful) {
178178
throw APIResponseException(
179179
"retrieve workspace",
@@ -196,8 +196,9 @@ open class CoderRestClient(
196196
* @throws [APIResponseException].
197197
*/
198198
suspend fun resources(workspace: Workspace): List<WorkspaceResource> {
199-
val resourcesResponse =
199+
val resourcesResponse = callWithRetry {
200200
retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID)
201+
}
201202
if (!resourcesResponse.isSuccessful) {
202203
throw APIResponseException(
203204
"retrieve resources for ${workspace.name}",
@@ -213,7 +214,7 @@ open class CoderRestClient(
213214
}
214215

215216
suspend fun buildInfo(): BuildInfo {
216-
val buildInfoResponse = retroRestClient.buildInfo()
217+
val buildInfoResponse = callWithRetry { retroRestClient.buildInfo() }
217218
if (!buildInfoResponse.isSuccessful) {
218219
throw APIResponseException(
219220
"retrieve build information",
@@ -232,7 +233,7 @@ open class CoderRestClient(
232233
* @throws [APIResponseException].
233234
*/
234235
private suspend fun template(templateID: UUID): Template {
235-
val templateResponse = retroRestClient.template(templateID)
236+
val templateResponse = callWithRetry { retroRestClient.template(templateID) }
236237
if (!templateResponse.isSuccessful) {
237238
throw APIResponseException(
238239
"retrieve template with ID $templateID",
@@ -258,7 +259,7 @@ open class CoderRestClient(
258259
null,
259260
WorkspaceBuildReason.JETBRAINS_CONNECTION
260261
)
261-
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
262+
val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) }
262263
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
263264
throw APIResponseException(
264265
"start workspace ${workspace.name}",
@@ -277,7 +278,7 @@ open class CoderRestClient(
277278
*/
278279
suspend fun stopWorkspace(workspace: Workspace): WorkspaceBuild {
279280
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP)
280-
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
281+
val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) }
281282
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
282283
throw APIResponseException(
283284
"stop workspace ${workspace.name}",
@@ -297,7 +298,7 @@ open class CoderRestClient(
297298
*/
298299
suspend fun removeWorkspace(workspace: Workspace) {
299300
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false)
300-
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
301+
val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) }
301302
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
302303
throw APIResponseException(
303304
"delete workspace ${workspace.name}",
@@ -322,7 +323,7 @@ open class CoderRestClient(
322323
val template = template(workspace.templateID)
323324
val buildRequest =
324325
CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START)
325-
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
326+
val buildResponse = callWithRetry { retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) }
326327
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
327328
throw APIResponseException(
328329
"update workspace ${workspace.name}",
@@ -337,6 +338,58 @@ open class CoderRestClient(
337338
}
338339
}
339340

341+
/**
342+
* Executes a Retrofit call with a retry mechanism specifically for expired certificates.
343+
*/
344+
private suspend fun <T> callWithRetry(block: suspend () -> Response<T>): Response<T> {
345+
return try {
346+
block()
347+
} catch (e: Exception) {
348+
if (context.settingsStore.requiresMTlsAuth && isCertExpired(e)) {
349+
context.logger.info("Certificate expired detected. Attempting refresh...")
350+
if (refreshCertificates()) {
351+
context.logger.info("Certificates refreshed, retrying the request...")
352+
return block()
353+
}
354+
}
355+
throw e
356+
}
357+
}
358+
359+
private fun isCertExpired(e: Exception): Boolean {
360+
return (e is javax.net.ssl.SSLHandshakeException || e is javax.net.ssl.SSLPeerUnverifiedException) &&
361+
e.message?.contains("certificate_expired", ignoreCase = true) == true
362+
}
363+
364+
private suspend fun refreshCertificates(): Boolean = withContext(Dispatchers.IO) {
365+
val command = context.settingsStore.readOnly().tls.certRefreshCommand
366+
if (command.isNullOrBlank()) return@withContext false
367+
368+
return@withContext try {
369+
val result = ProcessExecutor()
370+
.command(command.split(" ").toList())
371+
.exitValueNormal()
372+
.readOutput(true)
373+
.execute()
374+
375+
if (result.exitValue == 0) {
376+
context.logger.info("Certificate refresh successful. Reloading TLS and evicting pool.")
377+
tlsContext.reload()
378+
379+
// This is the "Magic Fix":
380+
// It forces OkHttp to close the broken HTTP/2 connection.
381+
httpClient.connectionPool.evictAll()
382+
return@withContext true
383+
} else {
384+
context.logger.error("Refresh command failed with code ${result.exitValue}")
385+
false
386+
}
387+
} catch (ex: Exception) {
388+
context.logger.error(ex, "Failed to execute refresh command")
389+
false
390+
}
391+
}
392+
340393
fun close() {
341394
httpClient.apply {
342395
dispatcher.executorService.shutdown()

src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt

Lines changed: 0 additions & 53 deletions
This file was deleted.

0 commit comments

Comments
 (0)