Skip to content

Commit 3f5c8c4

Browse files
committed
feat: automatic mTLS certificate regeneration and retry mechanism
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 3f5c8c4

File tree

13 files changed

+180
-31
lines changed

13 files changed

+180
-31
lines changed

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(

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,77 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) :
280280
override fun getAcceptedIssuers(): Array<X509Certificate> =
281281
otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers
282282
}
283+
284+
class ReloadableX509TrustManager(
285+
private val caPath: String?,
286+
) : X509TrustManager {
287+
@Volatile
288+
private var delegate: X509TrustManager = loadTrustManager()
289+
290+
private fun loadTrustManager(): X509TrustManager {
291+
val trustManagers = coderTrustManagers(caPath)
292+
return trustManagers.first { it is X509TrustManager } as X509TrustManager
293+
}
294+
295+
fun reload() {
296+
delegate = loadTrustManager()
297+
}
298+
299+
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
300+
delegate.checkClientTrusted(chain, authType)
301+
}
302+
303+
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
304+
delegate.checkServerTrusted(chain, authType)
305+
}
306+
307+
override fun getAcceptedIssuers(): Array<X509Certificate> {
308+
return delegate.acceptedIssuers
309+
}
310+
}
311+
312+
class ReloadableSSLSocketFactory(
313+
private val settings: ReadOnlyTLSSettings,
314+
) : SSLSocketFactory() {
315+
@Volatile
316+
private var delegate: SSLSocketFactory = loadSocketFactory()
317+
318+
private fun loadSocketFactory(): SSLSocketFactory {
319+
return coderSocketFactory(settings)
320+
}
321+
322+
fun reload() {
323+
delegate = loadSocketFactory()
324+
}
325+
326+
override fun getDefaultCipherSuites(): Array<String> = delegate.defaultCipherSuites
327+
328+
override fun getSupportedCipherSuites(): Array<String> = delegate.supportedCipherSuites
329+
330+
override fun createSocket(): Socket = delegate.createSocket()
331+
332+
override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket =
333+
delegate.createSocket(s, host, port, autoClose)
334+
335+
override fun createSocket(host: String?, port: Int): Socket = delegate.createSocket(host, port)
336+
337+
override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket =
338+
delegate.createSocket(host, port, localHost, localPort)
339+
340+
override fun createSocket(host: InetAddress?, port: Int): Socket = delegate.createSocket(host, port)
341+
342+
override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket =
343+
delegate.createSocket(address, port, localAddress, localPort)
344+
}
345+
346+
class ReloadableTlsContext(
347+
settings: ReadOnlyTLSSettings
348+
) {
349+
val sslSocketFactory = ReloadableSSLSocketFactory(settings)
350+
val trustManager = ReloadableX509TrustManager(settings.caPath)
351+
352+
fun reload() {
353+
sslSocketFactory.reload()
354+
trustManager.reload()
355+
}
356+
}

0 commit comments

Comments
 (0)