Skip to content

Commit 912237d

Browse files
authored
feat: automatic mTLS certificate regeneration and retry mechanism (#224)
This adds support for automatically recovering from SSL handshake errors when certificates expired. When an SSL error occurs, the plugin will now attempt to execute a configured external command to refresh certificates. If successful, the SSL context is reloaded and the failed request is transparently retried. This improves reliability in environments with short-lived or frequently rotating certificates. Netflix requested this, they don't have a reliable mechanism to detect and refresh the certificates before any major disruption in Coder Toolbox.
1 parent b7fa471 commit 912237d

File tree

14 files changed

+181
-31
lines changed

14 files changed

+181
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
- application name can now be displayed as the main title page instead of the URL
8+
- automatic mTLS certificate regeneration and retry mechanism
89

910
### Changed
1011

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,14 +414,14 @@ class CoderRemoteProvider(
414414
* Auto-login only on first the firs run if there is a url & token configured or the auth
415415
* should be done via certificates.
416416
*/
417-
private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requireTokenAuth)
417+
private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth)
418418

419419
fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank()
420420

421421
private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
422422
// Store the URL and token for use next time.
423423
context.settingsStore.updateLastUsedUrl(client.url)
424-
if (context.settingsStore.requireTokenAuth) {
424+
if (context.settingsStore.requiresTokenAuth) {
425425
context.secrets.storeTokenFor(client.url, client.token ?: "")
426426
context.logger.info("Deployment URL and token were stored and will be available for automatic connection")
427427
} else {

src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.coder.toolbox.sdk.v2.models.Workspace
1919
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
2020
import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW
2121
import com.coder.toolbox.util.InvalidVersionException
22+
import com.coder.toolbox.util.ReloadableTlsContext
2223
import com.coder.toolbox.util.SemVer
2324
import com.coder.toolbox.util.escape
2425
import com.coder.toolbox.util.escapeSubcommand
@@ -153,7 +154,8 @@ class CoderCLIManager(
153154
}
154155
val okHttpClient = CoderHttpClientBuilder.build(
155156
context,
156-
interceptors
157+
interceptors,
158+
ReloadableTlsContext(context.settingsStore.readOnly().tls)
157159
)
158160

159161
val retrofit = Retrofit.Builder()

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

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,19 @@ package com.coder.toolbox.sdk
22

33
import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.util.CoderHostnameVerifier
5-
import com.coder.toolbox.util.coderSocketFactory
6-
import com.coder.toolbox.util.coderTrustManagers
5+
import com.coder.toolbox.util.ReloadableTlsContext
76
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
87
import okhttp3.Credentials
98
import okhttp3.Interceptor
109
import okhttp3.OkHttpClient
11-
import javax.net.ssl.X509TrustManager
1210

1311
object CoderHttpClientBuilder {
1412
fun build(
1513
context: CoderToolboxContext,
16-
interceptors: List<Interceptor>
14+
interceptors: List<Interceptor>,
15+
tlsContext: ReloadableTlsContext
1716
): OkHttpClient {
18-
val settings = context.settingsStore.readOnly()
19-
20-
val socketFactory = coderSocketFactory(settings.tls)
21-
val trustManagers = coderTrustManagers(settings.tls.caPath)
22-
var builder = OkHttpClient.Builder()
17+
val builder = OkHttpClient.Builder()
2318

2419
context.proxySettings.getProxy()?.let { proxy ->
2520
context.logger.info("proxy: $proxy")
@@ -43,8 +38,8 @@ object CoderHttpClientBuilder {
4338
.build()
4439
}
4540

46-
builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
47-
.hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname))
41+
builder.sslSocketFactory(tlsContext.sslSocketFactory, tlsContext.trustManager)
42+
.hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname))
4843
.retryOnConnectionFailure(true)
4944

5045
interceptors.forEach { interceptor ->

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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
1011
import com.coder.toolbox.sdk.interceptors.Interceptors
1112
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
1213
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
@@ -20,6 +21,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuild
2021
import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason
2122
import com.coder.toolbox.sdk.v2.models.WorkspaceResource
2223
import com.coder.toolbox.sdk.v2.models.WorkspaceTransition
24+
import com.coder.toolbox.util.ReloadableTlsContext
2325
import com.squareup.moshi.Moshi
2426
import okhttp3.OkHttpClient
2527
import retrofit2.Response
@@ -40,6 +42,7 @@ open class CoderRestClient(
4042
val token: String?,
4143
private val pluginVersion: String = "development",
4244
) {
45+
private lateinit var tlsContext: ReloadableTlsContext
4346
private lateinit var moshi: Moshi
4447
private lateinit var httpClient: OkHttpClient
4548
private lateinit var retroRestClient: CoderV2RestFacade
@@ -60,12 +63,17 @@ open class CoderRestClient(
6063
.add(OSConverter())
6164
.add(UUIDConverter())
6265
.build()
66+
67+
tlsContext = ReloadableTlsContext(context.settingsStore.readOnly().tls)
68+
6369
val interceptors = buildList {
64-
if (context.settingsStore.requireTokenAuth) {
70+
if (context.settingsStore.requiresTokenAuth) {
6571
if (token.isNullOrBlank()) {
6672
throw IllegalStateException("Token is required for $url deployment")
6773
}
6874
add(Interceptors.tokenAuth(token))
75+
} else if (context.settingsStore.requiresMTlsAuth && context.settingsStore.tls.certRefreshCommand?.isNotBlank() == true) {
76+
add(CertificateRefreshInterceptor(context, tlsContext))
6977
}
7078
add((Interceptors.userAgent(pluginVersion)))
7179
add(Interceptors.externalHeaders(context, url))
@@ -74,7 +82,8 @@ open class CoderRestClient(
7482

7583
httpClient = CoderHttpClientBuilder.build(
7684
context,
77-
interceptors
85+
interceptors,
86+
tlsContext
7887
)
7988

8089
retroRestClient =
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.coder.toolbox.sdk.interceptors
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.util.ReloadableTlsContext
5+
import okhttp3.Interceptor
6+
import okhttp3.Response
7+
import org.zeroturnaround.exec.ProcessExecutor
8+
import javax.net.ssl.SSLHandshakeException
9+
import javax.net.ssl.SSLPeerUnverifiedException
10+
11+
class CertificateRefreshInterceptor(
12+
private val context: CoderToolboxContext,
13+
private val tlsContext: ReloadableTlsContext
14+
) : Interceptor {
15+
override fun intercept(chain: Interceptor.Chain): Response {
16+
val request = chain.request()
17+
try {
18+
return chain.proceed(request)
19+
} catch (e: Exception) {
20+
if ((e is SSLHandshakeException || e is SSLPeerUnverifiedException) && (e.message?.contains("certificate_expired") == true)) {
21+
val command = context.settingsStore.tls.certRefreshCommand
22+
if (command.isNullOrBlank()) {
23+
throw IllegalStateException(
24+
"Certificate expiration interceptor was set but the refresh command was removed in the meantime",
25+
e
26+
)
27+
}
28+
29+
context.logger.info("SSL handshake exception encountered: certificates expired. Running certificate refresh command: $command")
30+
try {
31+
val result = ProcessExecutor()
32+
.command(command.split(" ").toList())
33+
.exitValueNormal()
34+
.readOutput(true)
35+
.execute()
36+
context.logger.info("`$command`: ${result.outputUTF8()}")
37+
38+
if (result.exitValue == 0) {
39+
context.logger.info("Certificate refresh command executed successfully. Reloading SSL certificates.")
40+
tlsContext.reload()
41+
// Retry the request
42+
return chain.proceed(request)
43+
} else {
44+
context.logger.error("Certificate refresh command failed with exit code ${result.exitValue}")
45+
}
46+
} catch (ex: Exception) {
47+
context.logger.error(ex, "Failed to execute certificate refresh command")
48+
}
49+
}
50+
throw e
51+
}
52+
}
53+
}

src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ interface ReadOnlyCoderSettings {
114114
/**
115115
* Whether login should be done with a token
116116
*/
117-
val requireTokenAuth: Boolean
117+
val requiresTokenAuth: Boolean
118+
119+
/**
120+
* Whether the authentication is done with certificates.
121+
*/
122+
val requiresMTlsAuth: Boolean
118123

119124
/**
120125
* Whether to add --disable-autostart to the proxy command. This works
@@ -216,6 +221,12 @@ interface ReadOnlyTLSSettings {
216221
* Coder service does not match the hostname in the TLS certificate.
217222
*/
218223
val altHostname: String?
224+
225+
/**
226+
* Command to run when certificates expire and SSLHandshakeException
227+
* is raised with `Received fatal alert: certificate_expired` as message
228+
*/
229+
val certRefreshCommand: String?
219230
}
220231

221232
enum class SignatureFallbackStrategy {

src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class CoderSettingsStore(
3232
override val certPath: String?,
3333
override val keyPath: String?,
3434
override val caPath: String?,
35-
override val altHostname: String?
35+
override val altHostname: String?,
36+
override val certRefreshCommand: String?
3637
) : ReadOnlyTLSSettings
3738

3839
// Properties implementation
@@ -62,9 +63,11 @@ class CoderSettingsStore(
6263
certPath = store[TLS_CERT_PATH],
6364
keyPath = store[TLS_KEY_PATH],
6465
caPath = store[TLS_CA_PATH],
65-
altHostname = store[TLS_ALTERNATE_HOSTNAME]
66+
altHostname = store[TLS_ALTERNATE_HOSTNAME],
67+
certRefreshCommand = store[TLS_CERT_REFRESH_COMMAND]
6668
)
67-
override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank()
69+
override val requiresTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank()
70+
override val requiresMTlsAuth: Boolean get() = tls.certPath?.isNotBlank() == true && tls.keyPath?.isNotBlank() == true
6871
override val disableAutostart: Boolean
6972
get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC)
7073
override val isSshWildcardConfigEnabled: Boolean

src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ internal const val TLS_CA_PATH = "tlsCAPath"
3636

3737
internal const val TLS_ALTERNATE_HOSTNAME = "tlsAlternateHostname"
3838

39+
internal const val TLS_CERT_REFRESH_COMMAND = "tlsCertRefreshCommand"
40+
3941
internal const val DISABLE_AUTOSTART = "disableAutostart"
4042

4143
internal const val ENABLE_SSH_WILDCARD_CONFIG = "enableSshWildcardConfig"

src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ open class CoderProtocolHandler(
7070

7171
context.logger.info("Handling $uri...")
7272
val deploymentURL = resolveDeploymentUrl(params) ?: return
73-
val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return
73+
val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return
7474
val workspaceName = resolveWorkspaceName(params) ?: return
7575

7676
suspend fun onConnect(

0 commit comments

Comments
 (0)