diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ad0740..235d2957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - application name can now be displayed as the main title page instead of the URL +- automatic mTLS certificate regeneration and retry mechanism ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 6084880e..217d4b1e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -414,14 +414,14 @@ class CoderRemoteProvider( * Auto-login only on first the firs run if there is a url & token configured or the auth * should be done via certificates. */ - private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requireTokenAuth) + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.settingsStore.updateLastUsedUrl(client.url) - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { context.secrets.storeTokenFor(client.url, client.token ?: "") context.logger.info("Deployment URL and token were stored and will be available for automatic connection") } else { diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index eb289af6..0fe6c25e 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -19,6 +19,7 @@ import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW import com.coder.toolbox.util.InvalidVersionException +import com.coder.toolbox.util.ReloadableTlsContext import com.coder.toolbox.util.SemVer import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand @@ -153,7 +154,8 @@ class CoderCLIManager( } val okHttpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + ReloadableTlsContext(context.settingsStore.readOnly().tls) ) val retrofit = Retrofit.Builder() diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt index 86474d9c..a526db05 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -2,24 +2,19 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.CoderHostnameVerifier -import com.coder.toolbox.util.coderSocketFactory -import com.coder.toolbox.util.coderTrustManagers +import com.coder.toolbox.util.ReloadableTlsContext import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth import okhttp3.Credentials import okhttp3.Interceptor import okhttp3.OkHttpClient -import javax.net.ssl.X509TrustManager object CoderHttpClientBuilder { fun build( context: CoderToolboxContext, - interceptors: List + interceptors: List, + tlsContext: ReloadableTlsContext ): OkHttpClient { - val settings = context.settingsStore.readOnly() - - val socketFactory = coderSocketFactory(settings.tls) - val trustManagers = coderTrustManagers(settings.tls.caPath) - var builder = OkHttpClient.Builder() + val builder = OkHttpClient.Builder() context.proxySettings.getProxy()?.let { proxy -> context.logger.info("proxy: $proxy") @@ -43,8 +38,8 @@ object CoderHttpClientBuilder { .build() } - builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) - .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + builder.sslSocketFactory(tlsContext.sslSocketFactory, tlsContext.trustManager) + .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) .retryOnConnectionFailure(true) interceptors.forEach { interceptor -> diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 7023c764..b44352d3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,6 +7,7 @@ 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 @@ -20,6 +21,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuild import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason 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 okhttp3.OkHttpClient import retrofit2.Response @@ -40,6 +42,7 @@ open class CoderRestClient( val token: String?, private val pluginVersion: String = "development", ) { + private lateinit var tlsContext: ReloadableTlsContext private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade @@ -60,12 +63,17 @@ open class CoderRestClient( .add(OSConverter()) .add(UUIDConverter()) .build() + + tlsContext = ReloadableTlsContext(context.settingsStore.readOnly().tls) + val interceptors = buildList { - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { if (token.isNullOrBlank()) { 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)) @@ -74,7 +82,8 @@ open class CoderRestClient( httpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + tlsContext ) retroRestClient = diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt new file mode 100644 index 00000000..55dae436 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.ReloadableTlsContext +import okhttp3.Interceptor +import okhttp3.Response +import org.zeroturnaround.exec.ProcessExecutor +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class CertificateRefreshInterceptor( + private val context: CoderToolboxContext, + private val tlsContext: ReloadableTlsContext +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + try { + return chain.proceed(request) + } catch (e: Exception) { + if ((e is SSLHandshakeException || e is SSLPeerUnverifiedException) && (e.message?.contains("certificate_expired") == true)) { + val command = context.settingsStore.tls.certRefreshCommand + if (command.isNullOrBlank()) { + throw IllegalStateException( + "Certificate expiration interceptor was set but the refresh command was removed in the meantime", + e + ) + } + + context.logger.info("SSL handshake exception encountered: certificates expired. Running certificate refresh command: $command") + try { + val result = ProcessExecutor() + .command(command.split(" ").toList()) + .exitValueNormal() + .readOutput(true) + .execute() + context.logger.info("`$command`: ${result.outputUTF8()}") + + if (result.exitValue == 0) { + context.logger.info("Certificate refresh command executed successfully. Reloading SSL certificates.") + tlsContext.reload() + // Retry the request + return chain.proceed(request) + } else { + context.logger.error("Certificate refresh command failed with exit code ${result.exitValue}") + } + } catch (ex: Exception) { + context.logger.error(ex, "Failed to execute certificate refresh command") + } + } + throw e + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index edf4801f..689f2793 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -114,7 +114,12 @@ interface ReadOnlyCoderSettings { /** * Whether login should be done with a token */ - val requireTokenAuth: Boolean + val requiresTokenAuth: Boolean + + /** + * Whether the authentication is done with certificates. + */ + val requiresMTlsAuth: Boolean /** * Whether to add --disable-autostart to the proxy command. This works @@ -216,6 +221,12 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? + + /** + * Command to run when certificates expire and SSLHandshakeException + * is raised with `Received fatal alert: certificate_expired` as message + */ + val certRefreshCommand: String? } enum class SignatureFallbackStrategy { diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index ed8f009c..ab8e54be 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -32,7 +32,8 @@ class CoderSettingsStore( override val certPath: String?, override val keyPath: String?, override val caPath: String?, - override val altHostname: String? + override val altHostname: String?, + override val certRefreshCommand: String? ) : ReadOnlyTLSSettings // Properties implementation @@ -62,9 +63,11 @@ class CoderSettingsStore( certPath = store[TLS_CERT_PATH], keyPath = store[TLS_KEY_PATH], caPath = store[TLS_CA_PATH], - altHostname = store[TLS_ALTERNATE_HOSTNAME] + altHostname = store[TLS_ALTERNATE_HOSTNAME], + certRefreshCommand = store[TLS_CERT_REFRESH_COMMAND] ) - override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresMTlsAuth: Boolean get() = tls.certPath?.isNotBlank() == true && tls.keyPath?.isNotBlank() == true override val disableAutostart: Boolean get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC) override val isSshWildcardConfigEnabled: Boolean diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index bc46c4fd..c199aecd 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -36,6 +36,8 @@ internal const val TLS_CA_PATH = "tlsCAPath" internal const val TLS_ALTERNATE_HOSTNAME = "tlsAlternateHostname" +internal const val TLS_CERT_REFRESH_COMMAND = "tlsCertRefreshCommand" + internal const val DISABLE_AUTOSTART = "disableAutostart" internal const val ENABLE_SSH_WILDCARD_CONFIG = "enableSshWildcardConfig" diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 8e4dfbb3..113ab9f9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -70,7 +70,7 @@ open class CoderProtocolHandler( context.logger.info("Handling $uri...") val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return + val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return suspend fun onConnect( diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index 97a5df96..101370d2 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -280,3 +280,77 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) : override fun getAcceptedIssuers(): Array = otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers } + +class ReloadableX509TrustManager( + private val caPath: String?, +) : X509TrustManager { + @Volatile + private var delegate: X509TrustManager = loadTrustManager() + + private fun loadTrustManager(): X509TrustManager { + val trustManagers = coderTrustManagers(caPath) + return trustManagers.first { it is X509TrustManager } as X509TrustManager + } + + fun reload() { + delegate = loadTrustManager() + } + + override fun checkClientTrusted(chain: Array?, authType: String?) { + delegate.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + delegate.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array { + return delegate.acceptedIssuers + } +} + +class ReloadableSSLSocketFactory( + private val settings: ReadOnlyTLSSettings, +) : SSLSocketFactory() { + @Volatile + private var delegate: SSLSocketFactory = loadSocketFactory() + + private fun loadSocketFactory(): SSLSocketFactory { + return coderSocketFactory(settings) + } + + fun reload() { + delegate = loadSocketFactory() + } + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + override fun createSocket(): Socket = delegate.createSocket() + + override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket = + delegate.createSocket(s, host, port, autoClose) + + override fun createSocket(host: String?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket = + delegate.createSocket(host, port, localHost, localPort) + + override fun createSocket(host: InetAddress?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket = + delegate.createSocket(address, port, localAddress, localPort) +} + +class ReloadableTlsContext( + settings: ReadOnlyTLSSettings +) { + val sslSocketFactory = ReloadableSSLSocketFactory(settings) + val trustManager = ReloadableX509TrustManager(settings.caPath) + + fun reload() { + sslSocketFactory.reload() + trustManager.reload() + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index b6d0bbba..247d2c42 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -49,7 +49,7 @@ class ConnectStep( context.i18n.pnotr("") } - if (context.settingsStore.requireTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { errorField.textState.update { context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") } @@ -70,7 +70,7 @@ class ConnectStep( return } - if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -84,7 +84,7 @@ class ConnectStep( val client = CoderRestClient( context, url, - if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, + if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action @@ -98,7 +98,7 @@ class ConnectStep( statusField.textState.update { (context.i18n.pnotr(progress)) } } // We only need to log in if we are using token-based auth. - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() @@ -144,7 +144,7 @@ class ConnectStep( CoderCliSetupWizardState.goToFirstStep() } } else { - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { CoderCliSetupWizardState.goToPreviousStep() } else { CoderCliSetupWizardState.goToFirstStep() diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 27e53f97..b4a60668 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -86,7 +86,7 @@ class DeploymentUrlStep( errorReporter.report("URL is invalid", e) return false } - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { CoderCliSetupWizardState.goToNextStep() } else { CoderCliSetupWizardState.goToLastStep() diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index 50334876..9d38c4fe 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -261,19 +261,19 @@ internal class CoderSettingsTest { @Test fun testRequireTokenAuth() { var settings = CoderSettingsStore(pluginTestSettingsStore(), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_CERT_PATH to "cert path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_KEY_PATH to "key path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore( pluginTestSettingsStore(TLS_CERT_PATH to "cert path", TLS_KEY_PATH to "key path"), Environment(), logger ) - assertEquals(false, settings.readOnly().requireTokenAuth) + assertEquals(false, settings.readOnly().requiresTokenAuth) } @Test