From 4a5401c0e7865ef147c5ad462a5914e112fd5211 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:27:42 +0300 Subject: [PATCH 01/19] Changelog update - `v2.21.0` (#556) * Changelog update - v2.21.0 * chore: fake commit to trigger the build --------- Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a75e4a..d63fecdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.21.0 - 2025-06-25 + ### Changed - the logos and icons now match the new branding From dc0687ce9447c6b21e94011ea70026b48b00f063 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 26 Jun 2025 21:55:58 +0300 Subject: [PATCH 02/19] fix: marketplace logos should use the new design (#557) * fix: marketplace logos should use the new design # Conflicts: # CHANGELOG.md * chore: next version should be 2.21.1 --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- src/main/resources/META-INF/pluginIcon.svg | 16 ++-------------- src/main/resources/META-INF/pluginIcon_dark.svg | 16 ++-------------- 4 files changed, 9 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63fecdc..d97af735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Fixed + +- marketplace logo + ## 2.21.0 - 2025-06-25 ### Changed diff --git a/gradle.properties b/gradle.properties index 8f00c9e1..1d8cac79 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.21.0 +pluginVersion=2.21.1 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/META-INF/pluginIcon.svg index 15696c66..300ce0c2 100644 --- a/src/main/resources/META-INF/pluginIcon.svg +++ b/src/main/resources/META-INF/pluginIcon.svg @@ -1,15 +1,3 @@ - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/main/resources/META-INF/pluginIcon_dark.svg b/src/main/resources/META-INF/pluginIcon_dark.svg index 64d036ad..83e9a47b 100644 --- a/src/main/resources/META-INF/pluginIcon_dark.svg +++ b/src/main/resources/META-INF/pluginIcon_dark.svg @@ -1,15 +1,3 @@ - - - - - - - - - - - - - - + + From 16f6218ecb3d159c45a24d13a6186bd287d4cb68 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Thu, 3 Jul 2025 23:31:07 +0300 Subject: [PATCH 03/19] Add support for JetBrains 2025.2 EAP (252.*) (#560) Adds 2025.2 to verifyVersions to enable testing and support for JetBrains 2025.2 EAP builds with 252.* version numbers. This addresses customer requests for 2025.2 EAP compatibility. Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: matifali <10648092+matifali@users.noreply.github.com> --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 1d8cac79..4c6ce49b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,7 +27,7 @@ platformVersion=2024.3.6 # Gateway does not have open sources. platformDownloadSources=true # available releases listed at: https://data.services.jetbrains.com/products?code=GW -verifyVersions=2024.3.6,2025.1 +verifyVersions=2024.3.6,2025.1,2025.2 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 platformPlugins= From 26ac983c886cf9fc135ee3dd2810fdb1a80d9ef4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Jul 2025 23:45:05 +0300 Subject: [PATCH 04/19] Changelog update - v2.21.1 (#558) Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d97af735..8c954387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.21.1 - 2025-06-26 + ### Fixed - marketplace logo From 3c8828db458369ed70558ef00aa7d0c132194e54 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 22 Jul 2025 13:14:16 +0200 Subject: [PATCH 05/19] feat: set 'jetbrains_connection' as build reason on workspace start (#561) * Set 'jetbrains_connection' as build reason on workspace start * Fix tests --- .../com/coder/gateway/cli/CoderCLIManager.kt | 24 +++++++++++++------ .../com/coder/gateway/sdk/CoderRestClient.kt | 9 +++++-- .../v2/models/CreateWorkspaceBuildRequest.kt | 4 ++++ .../sdk/v2/models/WorkspaceBuildReason.kt | 7 ++++++ .../coder/gateway/cli/CoderCLIManagerTest.kt | 2 +- 5 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceBuildReason.kt diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index cc883a3b..197c32d1 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -116,6 +116,7 @@ data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, val wildcardSSH: Boolean = false, + val buildReason: Boolean = false, ) /** @@ -479,13 +480,21 @@ class CoderCLIManager( * * Throws if the command execution fails. */ - fun startWorkspace(workspaceOwner: String, workspaceName: String): String = exec( - "--global-config", - coderConfigPath.toString(), - "start", - "--yes", - workspaceOwner + "/" + workspaceName, - ) + fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String { + val args = mutableListOf( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + workspaceOwner + "/" + workspaceName + ) + + if (feats.buildReason) { + args.addAll(listOf("--reason", "jetbrains_connection")) + } + + return exec(*args.toTypedArray()) + } private fun exec(vararg args: String): String { val stdout = @@ -511,6 +520,7 @@ class CoderCLIManager( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), wildcardSSH = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), ) } } diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt index 71c6e1ba..3224f517 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt @@ -15,6 +15,7 @@ import com.coder.gateway.sdk.v2.models.User import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.sdk.v2.models.WorkspaceBuild +import com.coder.gateway.sdk.v2.models.WorkspaceBuildReason import com.coder.gateway.sdk.v2.models.WorkspaceResource import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.v2.models.WorkspaceTransition @@ -244,7 +245,7 @@ open class CoderRestClient( * @throws [APIResponseException]. */ fun stopWorkspace(workspace: Workspace): WorkspaceBuild { - val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP) + val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException("stop workspace ${workspace.name}", url, buildResponse) @@ -265,7 +266,11 @@ open class CoderRestClient( fun updateWorkspace(workspace: Workspace): WorkspaceBuild { val template = template(workspace.templateID) val buildRequest = - CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START) + CreateWorkspaceBuildRequest( + template.activeVersionID, + WorkspaceTransition.START, + WorkspaceBuildReason.JETBRAINS_CONNECTION + ) val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException("update workspace ${workspace.name}", url, buildResponse) diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/CreateWorkspaceBuildRequest.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/CreateWorkspaceBuildRequest.kt index 5f00ddc4..c00261e2 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/models/CreateWorkspaceBuildRequest.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/CreateWorkspaceBuildRequest.kt @@ -10,6 +10,8 @@ data class CreateWorkspaceBuildRequest( @Json(name = "template_version_id") val templateVersionID: UUID?, // Use to start and stop the workspace. @Json(name = "transition") val transition: WorkspaceTransition, + // Use to set build reason for a workspace. + @Json(name = "reason") val reason: WorkspaceBuildReason?, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -19,6 +21,7 @@ data class CreateWorkspaceBuildRequest( if (templateVersionID != other.templateVersionID) return false if (transition != other.transition) return false + if (reason != other.reason) return false return true } @@ -26,6 +29,7 @@ data class CreateWorkspaceBuildRequest( override fun hashCode(): Int { var result = templateVersionID?.hashCode() ?: 0 result = 31 * result + transition.hashCode() + result = 31 * result + (reason?.hashCode() ?: 0) return result } } diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceBuildReason.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceBuildReason.kt new file mode 100644 index 00000000..18d50342 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceBuildReason.kt @@ -0,0 +1,7 @@ +package com.coder.gateway.sdk.v2.models + +import com.squareup.moshi.Json + +enum class WorkspaceBuildReason { + @Json(name = "jetbrains_connection") JETBRAINS_CONNECTION, +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index 5ae754ec..73aae020 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -825,7 +825,7 @@ internal class CoderCLIManagerTest { listOf( Pair("2.5.0", Features(true)), Pair("2.13.0", Features(true, true)), - Pair("4.9.0", Features(true, true, true)), + Pair("4.9.0", Features(true, true, true, true)), Pair("2.4.9", Features(false)), Pair("1.0.1", Features(false)), ) From 0164c609aa9c9f3693c3e5b0f57d72229b6886ad Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 25 Jul 2025 21:32:14 +0300 Subject: [PATCH 06/19] impl: verify cli signature (#562) * impl: support for downloading and verifying cli signatures * fix: class cast exception * impl: embed the pgp public key as a plugin resource This is the key that validates if the gpg signature was tampered * chore: fix UTs related to CLI downloading For one thing some method signature changed, some methods are now suspending functions that will have to run in a coroutine in the tests. The second big issue is that now the download function requests user's input via a dialog * fix: download the correct CLI signature for Windows The signature for windows CLI follows the format: coder-windows-amd64.exe.asc Currently it is coded to coder-windows-amd64.asc which means the plugin always fail to find any signature for windows cli * chore: next version is 2.22.0 * impl: strict URL validation for the connection screen This commit rejects any URL that is opaque, not hierarchical, not using http or https protocol, or it misses the hostname. * impl: strict URL validation for the URI handling This commit rejects any URL that is opaque, not hierarchical, not using http or https protocol, or it misses the hostname. * fix: transform to url only after we checked the validation result * chore: update UT expected result --- CHANGELOG.md | 6 + build.gradle.kts | 2 + gradle.properties | 2 +- .../gateway/CoderRemoteConnectionHandle.kt | 2 +- .../gateway/CoderSettingsConfigurable.kt | 8 + .../com/coder/gateway/cli/CoderCLIManager.kt | 239 ++++++++++++------ .../cli/downloader/CoderDownloadApi.kt | 29 +++ .../cli/downloader/CoderDownloadService.kt | 238 +++++++++++++++++ .../gateway/cli/downloader/DownloadResult.kt | 23 ++ .../com/coder/gateway/cli/ex/Exceptions.kt | 2 + .../com/coder/gateway/cli/gpg/GPGVerifier.kt | 142 +++++++++++ .../gateway/cli/gpg/VerificationResult.kt | 15 ++ .../coder/gateway/settings/CoderSettings.kt | 97 ++++--- .../com/coder/gateway/util/LinkHandler.kt | 6 +- src/main/kotlin/com/coder/gateway/util/OS.kt | 11 +- .../kotlin/com/coder/gateway/util/SemVer.kt | 2 +- .../com/coder/gateway/util/URLExtensions.kt | 26 +- .../views/steps/CoderWorkspacesStepView.kt | 58 ++++- .../META-INF/trusted-keys/pgp-public.key | 99 ++++++++ .../messages/CoderGatewayBundle.properties | 4 + .../coder/gateway/cli/CoderCLIManagerTest.kt | 121 +++++++-- .../gateway/settings/CoderSettingsTest.kt | 142 ++++++++--- .../coder/gateway/util/URLExtensionsTest.kt | 61 +++++ 23 files changed, 1140 insertions(+), 195 deletions(-) create mode 100644 src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt create mode 100644 src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt create mode 100644 src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt create mode 100644 src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt create mode 100644 src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt create mode 100644 src/main/resources/META-INF/trusted-keys/pgp-public.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c954387..b2dbab4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## Unreleased +### Added + +- support for checking if CLI is signed +- improved progress reporting while downloading the CLI +- URL validation is stricter in the connection screen and URI protocol handler + ## 2.21.1 - 2025-06-26 ### Fixed diff --git a/build.gradle.kts b/build.gradle.kts index 9ea24a06..a126d001 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,8 @@ dependencies { testImplementation(kotlin("test")) // required by the unit tests testImplementation(kotlin("test-junit5")) + testImplementation("io.mockk:mockk:1.13.12") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") // required by IntelliJ test framework testImplementation("junit:junit:4.13.2") diff --git a/gradle.properties b/gradle.properties index 4c6ce49b..b3085324 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.21.1 +pluginVersion=2.22.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 790a2cd3..481e5aa7 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -66,7 +66,7 @@ class CoderRemoteConnectionHandle { private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") private val dialogUi = DialogUi(settings) - fun connect(getParameters: (indicator: ProgressIndicator) -> WorkspaceProjectIDE) { + fun connect(getParameters: suspend (indicator: ProgressIndicator) -> WorkspaceProjectIDE) { val clientLifetime = LifetimeDefinition() clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title")) { try { diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 18373983..2032dc69 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -68,6 +68,14 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"), ) }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) + .bindSelected(state::fallbackOnCoderForSignatures) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), + ) + }.layout(RowLayout.PARENT_GRID) row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) { textField().resizableColumn().align(AlignX.FILL) .bindText(state::headerCommand) diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index 197c32d1..c916450e 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -1,41 +1,42 @@ package com.coder.gateway.cli +import com.coder.gateway.cli.downloader.CoderDownloadApi +import com.coder.gateway.cli.downloader.CoderDownloadService +import com.coder.gateway.cli.downloader.DownloadResult import com.coder.gateway.cli.ex.MissingVersionException -import com.coder.gateway.cli.ex.ResponseException import com.coder.gateway.cli.ex.SSHConfigFormatException +import com.coder.gateway.cli.ex.UnsignedBinaryExecutionDeniedException +import com.coder.gateway.cli.gpg.GPGVerifier +import com.coder.gateway.cli.gpg.VerificationResult import com.coder.gateway.sdk.v2.models.User import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.settings.CoderSettings import com.coder.gateway.settings.CoderSettingsState import com.coder.gateway.util.CoderHostnameVerifier +import com.coder.gateway.util.DialogUi import com.coder.gateway.util.InvalidVersionException -import com.coder.gateway.util.OS import com.coder.gateway.util.SemVer import com.coder.gateway.util.coderSocketFactory +import com.coder.gateway.util.coderTrustManagers import com.coder.gateway.util.escape import com.coder.gateway.util.escapeSubcommand -import com.coder.gateway.util.getHeaders -import com.coder.gateway.util.getOS import com.coder.gateway.util.safeHost -import com.coder.gateway.util.sha1 import com.intellij.openapi.diagnostic.Logger import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonDataException import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import org.zeroturnaround.exec.ProcessExecutor +import retrofit2.Retrofit import java.io.EOFException -import java.io.FileInputStream import java.io.FileNotFoundException -import java.net.ConnectException -import java.net.HttpURLConnection import java.net.URL -import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardCopyOption -import java.util.zip.GZIPInputStream -import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.X509TrustManager /** * Version output from the CLI's version command. @@ -57,7 +58,7 @@ internal data class Version( * 6. Since the binary directory can be read-only, if downloading fails, start * from step 2 with the data directory. */ -fun ensureCLI( +suspend fun ensureCLI( deploymentURL: URL, buildVersion: String, settings: CoderSettings, @@ -72,6 +73,7 @@ fun ensureCLI( // the 304 method. val cliMatches = cli.matchesVersion(buildVersion) if (cliMatches == true) { + indicator?.invoke("Local CLI version matches server version: $buildVersion") return cli } @@ -79,7 +81,7 @@ fun ensureCLI( if (settings.enableDownloads) { indicator?.invoke("Downloading Coder CLI...") try { - cli.download() + cli.download(buildVersion, indicator) return cli } catch (e: java.nio.file.AccessDeniedException) { // Might be able to fall back to the data directory. @@ -95,12 +97,13 @@ fun ensureCLI( val dataCLI = CoderCLIManager(deploymentURL, settings, true) val dataCLIMatches = dataCLI.matchesVersion(buildVersion) if (dataCLIMatches == true) { + indicator?.invoke("Local CLI version from data directory matches server version: $buildVersion") return dataCLI } if (settings.enableDownloads) { - indicator?.invoke("Downloading Coder CLI...") - dataCLI.download() + indicator?.invoke("Downloading Coder CLI to the data directory...") + dataCLI.download(buildVersion, indicator) return dataCLI } @@ -129,78 +132,147 @@ class CoderCLIManager( private val settings: CoderSettings = CoderSettings(CoderSettingsState()), // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. - forceDownloadToData: Boolean = false, + private val forceDownloadToData: Boolean = false, ) { + private val downloader = createDownloadService() + private val gpgVerifier = GPGVerifier(settings) + val remoteBinaryURL: URL = settings.binSource(deploymentURL) val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") + private fun createDownloadService(): CoderDownloadService { + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory( + coderSocketFactory(settings.tls), + coderTrustManagers(settings.tls.caPath)[0] as X509TrustManager + ) + .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(deploymentURL.toString()) + .client(okHttpClient) + .build() + + val service = retrofit.create(CoderDownloadApi::class.java) + return CoderDownloadService(settings, service, deploymentURL, forceDownloadToData) + } + /** * Download the CLI from the deployment if necessary. */ - fun download(): Boolean { - val eTag = getBinaryETag() - val conn = remoteBinaryURL.openConnection() as HttpURLConnection - if (settings.headerCommand.isNotBlank()) { - val headersFromHeaderCommand = getHeaders(deploymentURL, settings.headerCommand) - for ((key, value) in headersFromHeaderCommand) { - conn.setRequestProperty(key, value) + suspend fun download(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): Boolean { + try { + val cliResult = withContext(Dispatchers.IO) { + downloader.downloadCli(buildVersion, showTextProgress) + }.let { result -> + when { + result.isSkipped() -> return false + result.isNotFound() -> throw IllegalStateException("Could not find Coder CLI") + result.isFailed() -> throw (result as DownloadResult.Failed).error + else -> result as DownloadResult.Downloaded + } } - } - if (eTag != null) { - logger.info("Found existing binary at $localBinaryPath; calculated hash as $eTag") - conn.setRequestProperty("If-None-Match", "\"$eTag\"") - } - conn.setRequestProperty("Accept-Encoding", "gzip") - if (conn is HttpsURLConnection) { - conn.sslSocketFactory = coderSocketFactory(settings.tls) - conn.hostnameVerifier = CoderHostnameVerifier(settings.tls.altHostname) - } - try { - conn.connect() - logger.info("GET ${conn.responseCode} $remoteBinaryURL") - when (conn.responseCode) { - HttpURLConnection.HTTP_OK -> { - logger.info("Downloading binary to $localBinaryPath") - Files.createDirectories(localBinaryPath.parent) - conn.inputStream.use { - Files.copy( - if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it, - localBinaryPath, - StandardCopyOption.REPLACE_EXISTING, - ) + var signatureResult = withContext(Dispatchers.IO) { + downloader.downloadSignature(showTextProgress) + } + + if (signatureResult.isNotDownloaded()) { + if (settings.fallbackOnCoderForSignatures) { + logger.info("Trying to download signature file from releases.coder.com") + signatureResult = withContext(Dispatchers.IO) { + downloader.downloadReleasesSignature(buildVersion, showTextProgress) + } + + // if we could still not download it, ask the user if he accepts the risk + if (signatureResult.isNotDownloaded()) { + val acceptsUnsignedBinary = DialogUi(settings) + .confirm( + "Security Warning", + "Could not fetch any signatures for ${cliResult.source} from releases.coder.com. Would you like to run it anyway?" + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") + } } - if (getOS() != OS.WINDOWS) { - localBinaryPath.toFile().setExecutable(true) + } else { + // we are not allowed to fetch signatures from releases.coder.com + // so we will ask the user if he wants to continue + val acceptsUnsignedBinary = DialogUi(settings) + .confirm( + "Security Warning", + "No signatures were found for ${cliResult.source} and fallback to releases.coder.com is not allowed. Would you like to run it anyway?" + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") } - return true } + } + + // we have the cli, and signature is downloaded, let's verify the signature + signatureResult = signatureResult as DownloadResult.Downloaded + gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result -> + when { + result.isValid() -> { + downloader.commit() + return true + } - HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at $localBinaryPath") - return false + else -> { + logFailure(result, cliResult, signatureResult) + // prompt the user if he wants to accept the risk + val shouldRunAnyway = DialogUi(settings) + .confirm( + "Security Warning", + "Could not verify the authenticity of the ${cliResult.source}, it may be tampered with. Would you like to run it anyway?" + ) + + if (shouldRunAnyway) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unverified CLI from ${cliResult.source} was denied by the user") + } + } } } - } catch (e: ConnectException) { - // Add the URL so this is more easily debugged. - throw ConnectException("${e.message} to $remoteBinaryURL") } finally { - conn.disconnect() + downloader.cleanup() } - throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } - /** - * Return the entity tag for the binary on disk, if any. - */ - private fun getBinaryETag(): String? = try { - sha1(FileInputStream(localBinaryPath.toFile())) - } catch (e: FileNotFoundException) { - null - } catch (e: Exception) { - logger.warn("Unable to calculate hash for $localBinaryPath", e) - null + private fun logFailure( + result: VerificationResult, + cliResult: DownloadResult.Downloaded, + signatureResult: DownloadResult.Downloaded + ) { + when { + result.isInvalid() -> { + val reason = (result as VerificationResult.Invalid).reason + logger.error("Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" } + .orEmpty()) + } + + result.signatureIsNotFound() -> { + logger.error("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist") + } + + else -> { + val failure = result as VerificationResult.Failed + UnsignedBinaryExecutionDeniedException(result.error.message) + logger.error("Failed to verify signature for ${cliResult.dst}", failure.error) + } + } } /** @@ -279,7 +351,8 @@ class CoderCLIManager( if (settings.sshLogDirectory.isNotBlank()) escape(settings.sshLogDirectory) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val backgroundProxyArgs = baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) + val backgroundProxyArgs = + baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) val extraConfig = if (settings.sshConfigOptions.isNotBlank()) { "\n" + settings.sshConfigOptions.prependIndent(" ") @@ -296,22 +369,22 @@ class CoderCLIManager( val blockContent = if (feats.wildcardSSH) { startBlock + System.lineSeparator() + - """ + """ Host ${getHostPrefix()}--* ProxyCommand ${proxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-- %h """.trimIndent() - .plus("\n" + sshOpts.prependIndent(" ")) - .plus(extraConfig) - .plus("\n\n") - .plus( - """ + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig) + .plus("\n\n") + .plus( + """ Host ${getHostPrefix()}-bg--* ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-bg-- %h """.trimIndent() - .plus("\n" + sshOpts.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) + - System.lineSeparator() + endBlock + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig), + ).replace("\n", System.lineSeparator()) + + System.lineSeparator() + endBlock } else if (workspaceNames.isEmpty()) { "" } else { @@ -330,7 +403,12 @@ class CoderCLIManager( .plus( """ Host ${getBackgroundHostName(it.first, currentUser, it.second)} - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)} + ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${ + getWorkspaceParts( + it.first, + it.second + ) + } """.trimIndent() .plus("\n" + sshOpts.prependIndent(" ")) .plus(extraConfig), @@ -444,6 +522,7 @@ class CoderCLIManager( is InvalidVersionException -> { logger.info("Got invalid version from $localBinaryPath: ${e.message}") } + else -> { // An error here most likely means the CLI does not exist or // it executed successfully but output no version which diff --git a/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt new file mode 100644 index 00000000..fa27fdc7 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt @@ -0,0 +1,29 @@ +package com.coder.gateway.cli.downloader + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.HeaderMap +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * Retrofit API for downloading CLI + */ +interface CoderDownloadApi { + @GET + @Streaming + suspend fun downloadCli( + @Url url: String, + @Header("If-None-Match") eTag: String? = null, + @HeaderMap headers: Map = emptyMap(), + @Header("Accept-Encoding") acceptEncoding: String = "gzip", + ): Response + + @GET + suspend fun downloadSignature( + @Url url: String, + @HeaderMap headers: Map = emptyMap() + ): Response +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt new file mode 100644 index 00000000..3c315dd0 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,238 @@ +package com.coder.gateway.cli.downloader + +import com.coder.gateway.cli.ex.ResponseException +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.util.OS +import com.coder.gateway.util.SemVer +import com.coder.gateway.util.getHeaders +import com.coder.gateway.util.getOS +import com.coder.gateway.util.sha1 +import com.intellij.openapi.diagnostic.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.FileInputStream +import java.net.HttpURLConnection.HTTP_NOT_FOUND +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_OK +import java.net.URI +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.zip.GZIPInputStream +import kotlin.io.path.name +import kotlin.io.path.notExists + +/** + * Handles the download steps of Coder CLI + */ +class CoderDownloadService( + private val settings: CoderSettings, + private val downloadApi: CoderDownloadApi, + private val deploymentUrl: URL, + forceDownloadToData: Boolean, +) { + private val remoteBinaryURL: URL = settings.binSource(deploymentUrl) + private val cliFinalDst: Path = settings.binPath(deploymentUrl, forceDownloadToData) + private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp") + + suspend fun downloadCli(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): DownloadResult { + val eTag = calculateLocalETag() + if (eTag != null) { + logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag") + } + val response = downloadApi.downloadCli( + url = remoteBinaryURL.toString(), + eTag = eTag?.let { "\"$it\"" }, + headers = getRequestHeaders() + ) + + return when (response.code()) { + HTTP_OK -> { + logger.info("Downloading binary to temporary $cliTempDst") + response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() + DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) + } + + HTTP_NOT_MODIFIED -> { + logger.info("Using cached binary at $cliFinalDst") + showTextProgress?.invoke("Using cached binary") + DownloadResult.Skipped + } + + else -> { + throw ResponseException( + "Unexpected response from $remoteBinaryURL", + response.code() + ) + } + } + } + + /** + * Renames the temporary binary file to its original destination name. + * The implementation will override sibling file that has the original + * destination name. + */ + suspend fun commit(): Path { + return withContext(Dispatchers.IO) { + logger.info("Renaming binary from $cliTempDst to $cliFinalDst") + Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING) + cliFinalDst.makeExecutable() + cliFinalDst + } + } + + /** + * Cleans up the temporary binary file if it exists. + */ + suspend fun cleanup() { + withContext(Dispatchers.IO) { + runCatching { Files.deleteIfExists(cliTempDst) } + .onFailure { ex -> + logger.warn("Failed to delete temporary CLI file: $cliTempDst", ex) + } + } + } + + private fun calculateLocalETag(): String? { + return try { + if (cliFinalDst.notExists()) { + return null + } + sha1(FileInputStream(cliFinalDst.toFile())) + } catch (e: Exception) { + logger.warn("Unable to calculate hash for $cliFinalDst", e) + null + } + } + + private fun getRequestHeaders(): Map { + return if (settings.headerCommand.isBlank()) { + emptyMap() + } else { + getHeaders(deploymentUrl, settings.headerCommand) + } + } + + private fun Response.saveToDisk( + localPath: Path, + showTextProgress: ((t: String) -> Unit)? = null, + buildVersion: String? = null + ): Path? { + val responseBody = this.body() ?: return null + Files.deleteIfExists(localPath) + Files.createDirectories(localPath.parent) + + val outputStream = Files.newOutputStream( + localPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + val contentEncoding = this.headers()["Content-Encoding"] + val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(responseBody.byteStream()) + } else { + responseBody.byteStream() + } + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalRead = 0L + // local path is a temporary filename, reporting the progress with the real name + val binaryName = localPath.name.removeSuffix(".tmp") + sourceStream.use { source -> + outputStream.use { sink -> + while (source.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalRead += bytesRead + val prettyBuildVersion = buildVersion ?: "" + showTextProgress?.invoke( + "$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded" + ) + } + } + } + return cliFinalDst + } + + + private fun Path.makeExecutable() { + if (getOS() != OS.WINDOWS) { + logger.info("Making $this executable...") + this.toFile().setExecutable(true) + } + } + + private fun Long.toHumanReadableSize(): String { + if (this < 1024) return "$this B" + + val kb = this / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) + } + + suspend fun downloadSignature(showTextProgress: ((t: String) -> Unit)? = null): DownloadResult { + return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders()) + } + + private suspend fun downloadSignature( + url: URL, + showTextProgress: ((t: String) -> Unit)? = null, + headers: Map = emptyMap() + ): DownloadResult { + val signatureURL = url.toURI().resolve(settings.defaultSignatureNameByOsAndArch).toURL() + val localSignaturePath = cliFinalDst.parent.resolve(settings.defaultSignatureNameByOsAndArch) + logger.info("Downloading signature from $signatureURL") + + val response = downloadApi.downloadSignature( + url = signatureURL.toString(), + headers = headers + ) + + return when (response.code()) { + HTTP_OK -> { + response.saveToDisk(localSignaturePath, showTextProgress) + DownloadResult.Downloaded(signatureURL, localSignaturePath) + } + + HTTP_NOT_FOUND -> { + logger.warn("Signature file not found at $signatureURL") + DownloadResult.NotFound + } + + else -> { + DownloadResult.Failed( + ResponseException( + "Failed to download signature from $signatureURL", + response.code() + ) + ) + } + } + + } + + suspend fun downloadReleasesSignature( + buildVersion: String, + showTextProgress: ((t: String) -> Unit)? = null + ): DownloadResult { + val semVer = SemVer.parse(buildVersion) + return downloadSignature( + URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(), + showTextProgress + ) + } + + companion object { + val logger = Logger.getInstance(CoderDownloadService::class.java.simpleName) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt b/src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt new file mode 100644 index 00000000..a0fcfc93 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt @@ -0,0 +1,23 @@ +package com.coder.gateway.cli.downloader + +import java.net.URL +import java.nio.file.Path + + +/** + * Result of a download operation + */ +sealed class DownloadResult { + object Skipped : DownloadResult() + object NotFound : DownloadResult() + data class Downloaded(val source: URL, val dst: Path) : DownloadResult() + data class Failed(val error: Exception) : DownloadResult() + + fun isSkipped(): Boolean = this is Skipped + + fun isNotFound(): Boolean = this is NotFound + + fun isFailed(): Boolean = this is Failed + + fun isNotDownloaded(): Boolean = this !is Downloaded +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt b/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt index 752ffaed..448847be 100644 --- a/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt +++ b/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt @@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message) class SSHConfigFormatException(message: String) : Exception(message) class MissingVersionException(message: String) : Exception(message) + +class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message) diff --git a/src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt b/src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt new file mode 100644 index 00000000..ec1040de --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt @@ -0,0 +1,142 @@ +package com.coder.gateway.cli.gpg + +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.cli.gpg.VerificationResult.Failed +import com.coder.gateway.cli.gpg.VerificationResult.Invalid +import com.coder.gateway.cli.gpg.VerificationResult.SignatureNotFound +import com.coder.gateway.cli.gpg.VerificationResult.Valid +import com.intellij.openapi.diagnostic.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSignatureList +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.inputStream + +class GPGVerifier( + private val settings: CoderSettings +) { + + suspend fun verifySignature( + cli: Path, + signature: Path, + ): VerificationResult { + return try { + if (!Files.exists(signature)) { + logger.warn("Signature file not found, skipping verification") + return SignatureNotFound + } + + val (signatureBytes, publicKeyRing) = withContext(Dispatchers.IO) { + val signatureBytes = Files.readAllBytes(signature) + val publicKeyRing = getCoderPublicKeyRings() + + Pair(signatureBytes, publicKeyRing) + } + return verifyDetachedSignature( + cliPath = cli, + signatureBytes = signatureBytes, + publicKeyRings = publicKeyRing + ) + } catch (e: Exception) { + logger.error("GPG signature verification failed", e) + Failed(e) + } + } + + private fun getCoderPublicKeyRings(): List { + try { + val coderPublicKey = javaClass.getResourceAsStream("/META-INF/trusted-keys/pgp-public.key") + ?.readAllBytes() ?: throw IllegalStateException("Trusted public key not found") + return loadPublicKeyRings(coderPublicKey) + } catch (e: Exception) { + throw PGPException("Failed to load Coder public GPG key", e) + } + } + + /** + * Load public key rings from bytes + */ + fun loadPublicKeyRings(publicKeyBytes: ByteArray): List { + return try { + val keyInputStream = ArmoredInputStream(ByteArrayInputStream(publicKeyBytes)) + val keyRingCollection = PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(keyInputStream), + JcaKeyFingerprintCalculator() + ) + keyRingCollection.keyRings.asSequence().toList() + } catch (e: Exception) { + throw PGPException("Failed to load public key ring", e) + } + } + + /** + * Verify a detached GPG signature + */ + fun verifyDetachedSignature( + cliPath: Path, + signatureBytes: ByteArray, + publicKeyRings: List + ): VerificationResult { + try { + val signatureInputStream = ArmoredInputStream(ByteArrayInputStream(signatureBytes)) + val pgpObjectFactory = JcaPGPObjectFactory(signatureInputStream) + val signatureList = pgpObjectFactory.nextObject() as? PGPSignatureList + ?: throw PGPException("Invalid signature format") + + if (signatureList.isEmpty) { + return Invalid("No signatures found in signature file") + } + + val signature = signatureList[0] + val publicKey = findPublicKey(publicKeyRings, signature.keyID) + ?: throw PGPException("Public key not found for signature") + + signature.init(JcaPGPContentVerifierBuilderProvider(), publicKey) + cliPath.inputStream().use { fileStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fileStream.read(buffer).also { bytesRead = it } != -1) { + signature.update(buffer, 0, bytesRead) + } + } + + val isValid = signature.verify() + logger.info("GPG signature verification result: $isValid") + if (isValid) { + return Valid + } + return Invalid() + } catch (e: Exception) { + logger.error("GPG signature verification failed", e) + return Failed(e) + } + } + + /** + * Find a public key across all key rings in the collection + */ + private fun findPublicKey( + keyRings: List, + keyId: Long + ): PGPPublicKey? { + keyRings.forEach { keyRing -> + keyRing.getPublicKey(keyId)?.let { return it } + } + return null + } + + companion object { + val logger = Logger.getInstance(GPGVerifier::class.java.simpleName) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt b/src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt new file mode 100644 index 00000000..5e7a94ff --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt @@ -0,0 +1,15 @@ +package com.coder.gateway.cli.gpg + +/** + * Result of signature verification + */ +sealed class VerificationResult { + object Valid : VerificationResult() + data class Invalid(val reason: String? = null) : VerificationResult() + data class Failed(val error: Exception) : VerificationResult() + object SignatureNotFound : VerificationResult() + + fun isValid(): Boolean = this == Valid + fun isInvalid(): Boolean = this is Invalid + fun signatureIsNotFound(): Boolean = this == SignatureNotFound +} diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index aa46ba57..31d64d9c 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -34,7 +34,7 @@ enum class Source { * Return a description of the source. */ fun description(name: String): String = when (this) { - CONFIG -> "This $name was pulled from your global CLI config." + CONFIG -> "This $name was pulled from your global CLI config." DEPLOYMENT_CONFIG -> "This $name was pulled from your deployment's CLI config." LAST_USED -> "This was the last used $name." QUERY -> "This $name was pulled from the Gateway link." @@ -63,6 +63,12 @@ open class CoderSettingsState( // Whether to allow the plugin to fall back to the data directory when the // CLI directory is not writable. open var enableBinaryDirectoryFallback: Boolean = false, + + /** + * Controls whether we fall back release.coder.com + */ + open var fallbackOnCoderForSignatures: Boolean = false, + // An external command that outputs additional HTTP headers added to all // requests. The command must output each header as `key=value` on its own // line. The following environment variables will be available to the @@ -154,6 +160,22 @@ open class CoderSettings( val enableBinaryDirectoryFallback: Boolean get() = state.enableBinaryDirectoryFallback + /** + * Controls whether we fall back release.coder.com + */ + val fallbackOnCoderForSignatures: Boolean + get() = state.fallbackOnCoderForSignatures + + /** + * Default CLI binary name based on OS and architecture + */ + val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) + + /** + * Default CLI signature name based on OS and architecture + */ + val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) + /** * A command to run to set headers for API calls. */ @@ -262,9 +284,8 @@ open class CoderSettings( */ fun binSource(url: URL): URL { state.binarySource.let { - val binaryName = getCoderCLIForOS(getOS(), getArch()) return if (it.isBlank()) { - url.withPath("/bin/$binaryName") + url.withPath("/bin/$defaultCliBinaryNameByOsAndArch") } else { logger.info("Using binary source override $it") try { @@ -306,12 +327,12 @@ open class CoderSettings( // SSH has not been configured yet, or using some other authorization mechanism. null } to - try { - Files.readString(dir.resolve("session")) - } catch (e: Exception) { - // SSH has not been configured yet, or using some other authorization mechanism. - null - } + try { + Files.readString(dir.resolve("session")) + } catch (e: Exception) { + // SSH has not been configured yet, or using some other authorization mechanism. + null + } } /** @@ -374,41 +395,37 @@ open class CoderSettings( } /** - * Return the name of the binary (with extension) for the provided OS and - * architecture. + * Returns the name of the binary (with extension) for the provided OS and architecture. */ - private fun getCoderCLIForOS( - os: OS?, - arch: Arch?, - ): String { - logger.info("Resolving binary for $os $arch") - if (os == null) { - logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") - return "coder-windows-amd64.exe" + private fun getCoderCLIForOS(os: OS?, arch: Arch?): String { + logger.debug("Resolving binary for $os $arch") + + val (osName, extension) = when (os) { + OS.WINDOWS -> "windows" to ".exe" + OS.LINUX -> "linux" to "" + OS.MAC -> "darwin" to "" + null -> { + logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") + return "coder-windows-amd64.exe" + } } - return when (os) { - OS.WINDOWS -> - when (arch) { - Arch.AMD64 -> "coder-windows-amd64.exe" - Arch.ARM64 -> "coder-windows-arm64.exe" - else -> "coder-windows-amd64.exe" - } - - OS.LINUX -> - when (arch) { - Arch.AMD64 -> "coder-linux-amd64" - Arch.ARM64 -> "coder-linux-arm64" - Arch.ARMV7 -> "coder-linux-armv7" - else -> "coder-linux-amd64" - } - OS.MAC -> - when (arch) { - Arch.AMD64 -> "coder-darwin-amd64" - Arch.ARM64 -> "coder-darwin-arm64" - else -> "coder-darwin-amd64" - } + val archName = when (arch) { + Arch.AMD64 -> "amd64" + Arch.ARM64 -> "arm64" + Arch.ARMV7 -> "armv7" + else -> "amd64" // default fallback } + + return "coder-$osName-$archName$extension" + } + + /** + * Returns the name of the signature file (.asc) for the provided OS and architecture. + */ + private fun getCoderSignatureForOS(os: OS?, arch: Arch?): String { + logger.debug("Resolving signature for $os $arch") + return "${getCoderCLIForOS(os, arch)}.asc" } companion object { diff --git a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index f802109c..6ac93efa 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt @@ -28,7 +28,7 @@ open class LinkHandler( * Throw if required arguments are not supplied or the workspace is not in a * connectable state. */ - fun handle( + suspend fun handle( parameters: Map, indicator: ((t: String) -> Unit)? = null, ): WorkspaceProjectIDE { @@ -37,6 +37,10 @@ open class LinkHandler( if (deploymentURL.isNullOrBlank()) { throw MissingArgumentException("Query parameter \"$URL\" is missing") } + val result = deploymentURL.validateStrictWebUrl() + if (result is WebUrlValidationResult.Invalid) { + throw IllegalArgumentException(result.reason) + } val queryTokenRaw = parameters.token() val queryToken = if (!queryTokenRaw.isNullOrBlank()) { diff --git a/src/main/kotlin/com/coder/gateway/util/OS.kt b/src/main/kotlin/com/coder/gateway/util/OS.kt index eecd13fb..8a3a364a 100644 --- a/src/main/kotlin/com/coder/gateway/util/OS.kt +++ b/src/main/kotlin/com/coder/gateway/util/OS.kt @@ -4,16 +4,16 @@ import java.util.Locale fun getOS(): OS? = OS.from(System.getProperty("os.name")) -fun getArch(): Arch? = Arch.from(System.getProperty("os.arch").lowercase(Locale.getDefault())) +fun getArch(): Arch? = Arch.from(System.getProperty("os.arch")?.lowercase(Locale.getDefault())) enum class OS { WINDOWS, LINUX, - MAC, - ; + MAC; companion object { - fun from(os: String): OS? = when { + fun from(os: String?): OS? = when { + os.isNullOrBlank() -> null os.contains("win", true) -> { WINDOWS } @@ -38,7 +38,8 @@ enum class Arch { ; companion object { - fun from(arch: String): Arch? = when { + fun from(arch: String?): Arch? = when { + arch.isNullOrBlank() -> null arch.contains("amd64", true) || arch.contains("x86_64", true) -> AMD64 arch.contains("arm64", true) || arch.contains("aarch64", true) -> ARM64 arch.contains("armv7", true) -> ARMV7 diff --git a/src/main/kotlin/com/coder/gateway/util/SemVer.kt b/src/main/kotlin/com/coder/gateway/util/SemVer.kt index eaf0034d..435bdb1b 100644 --- a/src/main/kotlin/com/coder/gateway/util/SemVer.kt +++ b/src/main/kotlin/com/coder/gateway/util/SemVer.kt @@ -1,6 +1,6 @@ package com.coder.gateway.util -class SemVer(private val major: Long = 0, private val minor: Long = 0, private val patch: Long = 0) : Comparable { +class SemVer(val major: Long = 0, val minor: Long = 0, val patch: Long = 0) : Comparable { init { require(major >= 0) { "Coder major version must be a positive number" } require(minor >= 0) { "Coder minor version must be a positive number" } diff --git a/src/main/kotlin/com/coder/gateway/util/URLExtensions.kt b/src/main/kotlin/com/coder/gateway/util/URLExtensions.kt index 1fdeeca4..1fec6617 100644 --- a/src/main/kotlin/com/coder/gateway/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/gateway/util/URLExtensions.kt @@ -1,10 +1,12 @@ package com.coder.gateway.util +import com.coder.gateway.util.WebUrlValidationResult.Invalid import java.net.IDN import java.net.URI import java.net.URL -fun String.toURL(): URL = URL(this) + +fun String.toURL(): URL = URI.create(this).toURL() fun URL.withPath(path: String): URL = URL( this.protocol, @@ -13,6 +15,28 @@ fun URL.withPath(path: String): URL = URL( if (path.startsWith("/")) path else "/$path", ) +fun String.validateStrictWebUrl(): WebUrlValidationResult = try { + val uri = URI(this) + + when { + uri.isOpaque -> Invalid("$this is opaque, instead of hierarchical") + !uri.isAbsolute -> Invalid("$this is relative, it must be absolute") + uri.scheme?.lowercase() !in setOf("http", "https") -> + Invalid("Scheme for $this must be either http or https") + + uri.authority.isNullOrBlank() -> + Invalid("$this does not have a hostname") + else -> WebUrlValidationResult.Valid + } +} catch (e: Exception) { + Invalid(e.message ?: "$this could not be parsed as a URI reference") +} + +sealed class WebUrlValidationResult { + object Valid : WebUrlValidationResult() + data class Invalid(val reason: String) : WebUrlValidationResult() +} + /** * Return the host, converting IDN to ASCII in case the file system cannot * support the necessary character set. diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 1928a4c4..51a7df4b 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -14,14 +14,17 @@ import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.v2.models.toAgentList import com.coder.gateway.services.CoderRestClientService import com.coder.gateway.services.CoderSettingsService +import com.coder.gateway.services.CoderSettingsStateService import com.coder.gateway.settings.Source import com.coder.gateway.util.DialogUi import com.coder.gateway.util.InvalidVersionException import com.coder.gateway.util.OS import com.coder.gateway.util.SemVer +import com.coder.gateway.util.WebUrlValidationResult import com.coder.gateway.util.humanizeConnectionError import com.coder.gateway.util.isCancellation import com.coder.gateway.util.toURL +import com.coder.gateway.util.validateStrictWebUrl import com.coder.gateway.util.withoutNull import com.intellij.icons.AllIcons import com.intellij.ide.ActivityTracker @@ -40,6 +43,7 @@ import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.AnActionButton import com.intellij.ui.RelativeFont import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.AlignY import com.intellij.ui.dsl.builder.BottomGap @@ -76,6 +80,8 @@ import javax.swing.JLabel import javax.swing.JTable import javax.swing.JTextField import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.TableCellRenderer @@ -116,6 +122,7 @@ class CoderWorkspacesStepView : CoderWizardStep( CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.next.text"), ) { + private val state: CoderSettingsStateService = service() private val settings: CoderSettingsService = service() private val dialogUi = DialogUi(settings) private val cs = CoroutineScope(Dispatchers.Main) @@ -215,6 +222,31 @@ class CoderWorkspacesStepView : // Reconnect when the enter key is pressed. maybeAskTokenThenConnect() } + // Add document listener to clear error when user types + document.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) = clearErrorState() + override fun removeUpdate(e: DocumentEvent?) = clearErrorState() + override fun changedUpdate(e: DocumentEvent?) = clearErrorState() + + private fun clearErrorState() { + tfUrlComment?.apply { + foreground = UIUtil.getContextHelpForeground() + if (tfUrl?.text.equals(client?.url?.toString())) { + text = + CoderGatewayBundle.message( + "gateway.connector.view.coder.workspaces.connect.text.connected", + client!!.url.host, + ) + } else { + text = CoderGatewayBundle.message( + "gateway.connector.view.coder.workspaces.connect.text.comment", + CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text"), + ) + } + icon = null + } + } + }) }.component button(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text")) { // Reconnect when the connect button is pressed. @@ -262,6 +294,19 @@ class CoderWorkspacesStepView : ) }.layout(RowLayout.PARENT_GRID) } + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) + .bindSelected(state::fallbackOnCoderForSignatures).applyToComponent { + addActionListener { event -> + state.fallbackOnCoderForSignatures = (event.source as JBCheckBox).isSelected + } + } + .comment( + CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), + ) + + }.layout(RowLayout.PARENT_GRID) row { scrollCell( toolbar.createPanel().apply { @@ -520,8 +565,17 @@ class CoderWorkspacesStepView : private fun maybeAskTokenThenConnect(error: String? = null) { val oldURL = fields.coderURL component.apply() // Force bindings to be filled. - val newURL = fields.coderURL.toURL() if (settings.requireTokenAuth) { + val result = fields.coderURL.validateStrictWebUrl() + if (result is WebUrlValidationResult.Invalid) { + tfUrlComment.apply { + this?.foreground = UIUtil.getErrorForeground() + this?.text = result.reason + this?.icon = UIUtil.getBalloonErrorIcon() + } + return + } + val newURL = fields.coderURL.toURL() val pastedToken = dialogUi.askToken( newURL, @@ -536,7 +590,7 @@ class CoderWorkspacesStepView : maybeAskTokenThenConnect(it) } } else { - connect(newURL, null) + connect(fields.coderURL.toURL(), null) } } diff --git a/src/main/resources/META-INF/trusted-keys/pgp-public.key b/src/main/resources/META-INF/trusted-keys/pgp-public.key new file mode 100644 index 00000000..fb5c4c50 --- /dev/null +++ b/src/main/resources/META-INF/trusted-keys/pgp-public.key @@ -0,0 +1,99 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGPGrCwBEAC7SSKQIFoQdt3jYv/1okRdoleepLDG4NfcG52S45Ex3/fUA6Z/ +ewHQrx//SN+h1FLpb0zQMyamWrSh2O3dnkWridwlskb5/y8C/6OUdk4L/ZgHeyPO +Ncbyl1hqO8oViakiWt4IxwSYo83eJHxOUiCGZlqV6EpEsaur43BRHnK8EciNeIxF +Bjle3yXH1K3EgGGHpgnSoKe1nSVxtWIwX45d06v+VqnBoI6AyK0Zp+Nn8bL0EnXC +xGYU3XOkC6EmITlhMju1AhxnbkQiy8IUxXiaj3NoPc1khapOcyBybhESjRZHlgu4 +ToLZGaypjtfQJgMeFlpua7sJK0ziFMW4wOTX+6Ix/S6XA80dVbl3VEhSMpFCcgI+ +OmEd2JuBs6maG+92fCRIzGAClzV8/ifM//JU9D7Qlq6QJpcbNClODlPNDNe7RUEO +b7Bu7dJJS3VhHO9eEen6m6vRE4DNriHT4Zvq1UkHfpJUW7njzkIYRni3eNrsr4Da +U/eeGbVipok4lzZEOQtuaZlX9ytOdGrWEGMGSosTOG6u6KAKJoz7cQGZiz4pZpjR +3N2SIYv59lgpHrIV7UodGx9nzu0EKBhkoulaP1UzH8F16psSaJXRjeyl/YP8Rd2z +SYgZVLjTzkTUXkJT8fQO8zLBEuwA0IiXX5Dl7grfEeShANVrM9LVu8KkUwARAQAB +tC5Db2RlciBSZWxlYXNlIFNpZ25pbmcgS2V5IDxzZWN1cml0eUBjb2Rlci5jb20+ +iQJUBBMBCgA+FiEEKMY4lDj2Q3PIwvSKi87Yfbu4ZEsFAmPGrCwCGwMFCQWjmoAF +CwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQi87Yfbu4ZEvrQQ//a3ySdMVhnLP+ +KneonV2zuNilTMC2J/MNG7Q0hU+8I9bxCc6DDqcnBBCQkIUwJq3wmelt3nTC8RxI +fv+ggnbdF9pz7Fc91nIJsGlWpH+bu1tSIvKF/rzZA8v6xUblFFfaC7Gsc5P4xk/+ +h0XBDAy6K+7+AafgLFpRD08Y0Kf2aMcqdM6c2Zo4IPo6FNrOa66FNkypZdQ4IByW +4kMezZSTp4Phqd9yqGC4m44U8YgzmW9LHgrvS0JyIaRPcQFM31AJ50K3iYRxL1ll +ETqJvbDR8UORNQs3Qs3CEZL588BoDMX2TYObTCG6g9Om5vJT0kgUkjDxQHwbAj6E +z9j8BoWkDT2JNzwdfTbPueuRjO+A+TXA9XZtrzbEYEzh0sD9Bdr7ozSF3JAs4GZS +nqcVlyp7q44ZdePR9L8w0ksth56tBWHfE9hi5jbRDRY2OnkV7y7JtWnBDQx9bCIo +7L7aBT8eirI1ZOnUxHJrnqY5matfWjSDBFW+YmWUkjnzBsa9F4m8jq9MSD3Q/8hN +ksJFrmLQs0/8hnM39tS7kLnAaWeGvbmjnxdeMqZsICxNpbyQrq2AhF4GhWfc+NsZ +yznVagJZ9bIlGsycSXJbsA5GbXDnm172TlodMUbLF9FU8i0vV4Y7q6jKO/VsblKU +F0bhXIRqVLrd9g88IyVyyZozmwbJKIy5Ag0EY8asLAEQAMgI9bMurq6Zic4s5W0u +W6LBDHyZhe+w2a3oT/i2YgTsh8XmIjrNasYYWO67b50JKepA3fk3ZA44w8WJqq+z +HLpslEb2fY5I1HvENUMKjYAUIsswSC21DSBau4yYiRGF0MNqv/MWy5Rjc993vIU4 +4TM3mvVhPrYfIkr0jwSbxq8+cm3sBjr0gcBQO57C3w8QkcZ6jefuI7y+1ZeM7X3L +OngmBFJDEutd9LPO/6Is4j/iQfTb8WDR6OmMX3Y04RHrP4sm7jf+3ZZKjcFCZQjr +QA4XHcQyJjnMN34Fn1U7KWopivU+mqViAnVpA643dq9SiBqsl83/R03DrpwKpP7r +6qasUHSUULuS7A4n8+CDwK5KghvrS0hOwMiYoIwZIVPITSUFHPYxrCJK7gU2OHfk +IZHX5m9L5iNwLz958GwzwHuONs5bjMxILbKknRhEBOcbhcpk0jswiPNUrEdipRZY +GR9G9fzD6q4P5heV3kQRqyUUTxdDj8w7jbrwl8sm5zk+TMnPRsu2kg0uwIN1aILm +oVkDN5CiZtg00n2Fu3do5F3YkF0Cz7indx5yySr5iUuoCY0EnpqSwourJ/ZdZA9Y +ZCHjhgjwyPCbxpTGfLj1g25jzQBYn5Wdgr2aHCQcqnU8DKPCnYL9COHJJylgj0vN +NSxyDjNXYYwSrYMqs/91f5xVABEBAAGJAjwEGAEKACYWIQQoxjiUOPZDc8jC9IqL +zth9u7hkSwUCY8asLAIbDAUJBaOagAAKCRCLzth9u7hkSyMvD/0Qal5kwiKDjgBr +i/dtMka+WNBTMb6vKoM759o33YAl22On5WgLr9Uz0cjkJPtzMHxhUo8KQmiPRtsK +dOmG9NI9NttfSeQVbeL8V/DC672fWPKM4TB8X7Kkj56/KI7ueGRokDhXG2pJlhQr +HwzZsAKoCMMnjcquAhHJClK9heIpVLBGFVlmVzJETzxo6fbEU/c7L79+hOrR4BWx +Tg6Dk7mbAGe7BuQLNtw6gcWUVWtHS4iYQtE/4khU1QppC1Z/ZbZ+AJT2TAFXzIaw +0l9tcOh7+TXqsvCLsXN0wrUh1nOdxA81sNWEMY07bG1qgvHyVc7ZYM89/ApK2HP+ +bBDIpAsRCGu2MHtrnJIlNE1J14G1mnauR5qIqI3C0R5MPLXOcDtp+gnjFe+PLU+6 +rQxJObyOkyEpOvtVtJKfFnpI5bqyl8WEPN0rDaS2A27cGXi5nynSAqoM1xT15W21 +uyY2GXY26DIwVfc59wGeclwcM29nS7prRU3KtskjonJ0iQoQebYOHLxy896cK+pK +nnhZx5AQjYiZPsPktSNZjSuOvTZ3g+IDwbCSvmBHcQpitzUOPShTUTs0QjSttzk2 +I6WxP9ivoR9yJGsxwNgCgrYdyt5+hyXXW/aUVihnQwizQRbymjJ2/z+I8NRFIeYb +xbtNFaH3WjLnhm9CB/H+Lc8fUj6HaZkCDQRjxt6QARAAsjZuCMjZBaAC1LFMeRcv +9+Ck7T5UNXTL9xQr1jUFZR95I6loWiWvFJ3Uet7gIbgNYY5Dc1gDr1Oqx9KQBjsN +TUahXov5lmjF5mYeyWTDZ5TS8H3o50zQzfZRC1eEbqjiBMLAHv74KD13P62nvzv6 +Dejwc7Nwc6aOH3cdZm74kz4EmdobJYRVdd5X9EYH/hdM928SsipKhm44oj3RDGi/ +x+ptjW9gr0bnrgCbkyCMNKhnmHSM60I8f4/viRItb+hWRpZYfLxMGTBVunicSXcX +Zh6Fq/DD/yTjzN9N83/NdDvwCyKo5U/kPgD2Ixh5PyJ38cpz6774Awnb/tstCI1g +glnlNbu8Qz84STr3NRZMOgT5h5b5qASOeruG4aVo9euaYJHlnlgcoUmpbEMnwr0L +tREUXSHGXWor7EYPjUQLskIaPl9NCZ3MEw5LhsZTgEdFBnb54dxMSEl7/MYDYhD/ +uTIWOJmtsWHmuMmvfxnw5GDEhJnAp4dxUm9BZlJhfnVR07DtTKyEk37+kl6+i0ZQ +yU4HJ2GWItpLfK54E/CH+S91y7wpepb2TMkaFR2fCK0vXTGAXWK+Y+aTD8ZcLB5y +0IYPsvA0by5AFpmXNfWZiZtYvgJ5FAQZNuB5RILg3HsuDq2U4wzp5BoohWtsOzsn +antIUf/bN0D2g+pCySkc5ssAEQEAAbQuQ29kZXIgUmVsZWFzZSBTaWduaW5nIEtl +eSA8c2VjdXJpdHlAY29kZXIuY29tPokCVAQTAQoAPhYhBCHJaxy5UHGIdPZNvWpa +ZxteQKO5BQJjxt6QAhsDBQkFo5qABQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJ +EGpaZxteQKO5oysP/1rSdvbKMzozvnVZoglnPjnSGStY9Pr2ziGL7eIMk2yt+Orr +j/AwxYIDgsZPQoJEr87eX2dCYtUMM1x+CpZsWu8dDVFLxyZp8nPmhUzcUCFfutw1 +UmAVKQkOra9segZtw4HVcSctpdgLw7NHq7vIQm4knIvjWmdC15r1B6/VJJI8CeaR +Zy+ToPr9fKnYs1RNdz+DRDN2521skX1DaInhB/ALeid90rJTRujaP9XeyNb9k32K +qd3h4C0KUGIf0fNKj4mmDlNosX3V/pJZATpFiF8aVPlybHQ2W5xpn1U8FJxE4hgR +rvsZmO685Qwm6p/uRI5Eymfm8JC5OQNt9Kvs/BMhotsW0u+je8UXwnznptMILpVP ++qxNuHUe1MYLdjK21LFF+Pk5O4W1TT6mKcbisOmZuQMG5DxpzUwm1Rs5AX1omuJt +iOrmQEvmrKKWC9qbcmWW1t2scnIJsNtrsvME0UjJFz+RL6UUX3xXlLK6YOUghCr8 +gZ7ZPgFqygS6tMu8TAGURzSCfijDh+eZGwqrlvngBIaO5WiNdSXC/J9aE1KThXmX +90A3Gwry+yI2kRS7o8vmghXewPTZbnG0CVHiQIH2yqFNXnhKvhaJt0g04TcnxBte +kiFqRT4K1Bb7pUIlUANmrKo9/zRCxIOopEgRH5cVQ8ZglkT0t5d3ePmAo6h0uQIN +BGPG3pABEADghhNByVoC+qCMo+SErjxz9QYA+tKoAngbgPyxxyB4RD52Z58MwVaP ++Yk0qxJYUBat3dJwiCTlUGG+yTyMOwLl7qSDr53AD5ml0hwJqnLBJ6OUyGE4ax4D +RUVBprKlDltwr98cZDgzvwEhIO2T3tNZ4vySveITj9pLonOrLkAfGXqFOqom+S37 +6eZvjKTnEUbT+S0TTynwds70W31sxVUrL62qsUnmoKEnsKXk/7X8CLXWvtNqu9kf +eiXs5Jz4N6RZUqvS0WOaaWG9v1PHukTtb8RyeookhsBqf9fWOlw5foel+NQwGQjz +0D0dDTKxn2Taweq+gWNCRH7/FJNdWa9upZ2fUAjg9hN9Ow8Y5nE3J0YKCBAQTgNa +XNtsiGQjdEKYZslxZKFM34By3LD6IrkcAEPKu9plZthmqhQumqwYRAgB9O56jg3N +GDDRyAMS7y63nNphTSatpOZtPVVMtcBw5jPjMIPFfU2dlfsvmnCvru2dvfAij+Ng +EkwOLNS8rFQHMJSQysmHuAPSYT97Yl022mPrAtb9+hwtCXt3VI6dvIARl2qPyF0D +DMw2fW5E7ivhUr2WEFiBmXunrJvMIYldBzDkkBjamelPjoevR0wfoIn0x1CbSsQi +zbEs3PXHs7nGxb9TZnHY4+J94mYHdSXrImAuH/x97OnlfUpOKPv5lwARAQABiQI8 +BBgBCgAmFiEEIclrHLlQcYh09k29alpnG15Ao7kFAmPG3pACGwwFCQWjmoAACgkQ +alpnG15Ao7m2/g//Y/YRM+Qhf71G0MJpAfym6ZqmwsT78qQ8T9w95ZeIRD7UUE8d +tm39kqJTGP6DuHCNYEMs2M88o0SoQsS/7j/8is7H/13F5o40DWjuQphia2BWkB1B +G4QRRIXMlrPX8PS92GDCtGfvxn90Li2FhQGZWlNFwvKUB7+/yLMsZzOwo7BS6PwC +hvI3eC7DBC8sXjJUxsrgFAkxQxSx/njP8f4HdUwhNnB1YA2/5IY5bk8QrXxzrAK1 +sbIAjpJdtPYOrZByyyj4ZpRcSm3ngV2n8yd1muJ5u+oRIQoGCdEIaweCj598jNFa +k378ZA11hCyNFHjpPIKnF3tfsQ8vjDatoq4Asy+HXFuo1GA/lvNgNb3Nv4FUozuv +JYJ0KaW73FZXlFBIBkMkRQE8TspHy2v/IGyNXBwKncmkszaiiozBd+T+1NUZgtk5 +9o5uKQwLHVnHIU7r/w/oN5LvLawLg2dP/f2u/KoQXMxjwLZncSH4+5tRz4oa/GMn +k4F84AxTIjGfLJeXigyP6xIPQbvJy+8iLRaCpj+v/EPwAedbRV+u0JFeqqikca70 +aGN86JBOmwpU87sfFxLI7HdI02DkvlxYYK3vYlA6zEyWaeLZ3VNr6tHcQmOnFe8Q +26gcS0AQcxQZrcWTCZ8DJYF+RnXjSVRmHV/3YDts4JyMKcD6QX8s/3aaldk= +=dLmT +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index f318012e..3364e6f3 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -75,6 +75,10 @@ gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to d gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \ box will allow the plugin to fall back to the data directory when the CLI \ directory is not writable. + +gateway.connector.settings.fallback-on-coder-for-signatures.title=Fall back on releases.coder.com for signatures +gateway.connector.settings.fallback-on-coder-for-signatures.comment=Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment + gateway.connector.settings.header-command.title=Header command gateway.connector.settings.header-command.comment=An external command that \ outputs additional HTTP headers added to all requests. The command must \ diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index 73aae020..f0d82769 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -10,6 +10,7 @@ import com.coder.gateway.settings.CODER_SSH_CONFIG_OPTIONS import com.coder.gateway.settings.CoderSettings import com.coder.gateway.settings.CoderSettingsState import com.coder.gateway.settings.Environment +import com.coder.gateway.util.DialogUi import com.coder.gateway.util.InvalidVersionException import com.coder.gateway.util.OS import com.coder.gateway.util.SemVer @@ -19,6 +20,9 @@ import com.coder.gateway.util.sha1 import com.coder.gateway.util.toURL import com.squareup.moshi.JsonEncodingException import com.sun.net.httpserver.HttpServer +import io.mockk.every +import io.mockk.mockkConstructor +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException @@ -28,7 +32,8 @@ import java.net.InetSocketAddress import java.net.URL import java.nio.file.AccessDeniedException import java.nio.file.Path -import java.util.* +import java.util.UUID +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -37,6 +42,9 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +private const val VERSION_FOR_PROGRESS_REPORTING = "v2.13.1-devel+de07351b8" +private val noOpTextProgress: (String) -> Unit = { _ -> } + internal class CoderCLIManagerTest { /** * Return the contents of a script that contains the string. @@ -65,6 +73,9 @@ internal class CoderCLIManagerTest { if (exchange.requestURI.path == "/bin/override") { code = HttpURLConnection.HTTP_OK response = mkbinVersion("0.0.0") + } else if (exchange.requestURI.path.contains(".asc")) { + code = HttpURLConnection.HTTP_NOT_FOUND + response = "not found" } else if (!exchange.requestURI.path.startsWith("/bin/coder-")) { code = HttpURLConnection.HTTP_NOT_FOUND response = "not found" @@ -85,6 +96,13 @@ internal class CoderCLIManagerTest { return Pair(srv, URL("http://localhost:" + srv.address.port)) } + @BeforeTest + fun setup() { + // Mock the DialogUi constructor + mockkConstructor(DialogUi::class) + every { anyConstructed().confirm(any(), any()) } returns true + } + @Test fun testServerInternalError() { val (srv, url) = mockServer(HttpURLConnection.HTTP_INTERNAL_ERROR) @@ -93,7 +111,7 @@ internal class CoderCLIManagerTest { val ex = assertFailsWith( exceptionClass = ResponseException::class, - block = { ccm.download() }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } } ) assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code) @@ -145,7 +163,7 @@ internal class CoderCLIManagerTest { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ccm.download() }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } }, ) srv.stop(0) @@ -168,15 +186,16 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("real-cli").toString(), + fallbackOnCoderForSignatures = true ), ), ) - assertTrue(ccm.download()) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertDoesNotThrow { ccm.version() } // It should skip the second attempt. - assertFalse(ccm.download()) + assertFalse(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Make sure login failures propagate. assertFailsWith( @@ -194,16 +213,17 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("mock-cli").toString(), + fallbackOnCoderForSignatures = true ), binaryName = "coder.bat", ), ) - assertEquals(true, ccm.download()) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) // It should skip the second attempt. - assertEquals(false, ccm.download()) + assertEquals(false, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Should use the source override. ccm = @@ -213,11 +233,12 @@ internal class CoderCLIManagerTest { CoderSettingsState( binarySource = "/bin/override", dataDirectory = tmpdir.resolve("mock-cli").toString(), + fallbackOnCoderForSignatures = true ), ), ) - assertEquals(true, ccm.download()) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0") srv.stop(0) @@ -242,7 +263,7 @@ internal class CoderCLIManagerTest { } @Test - fun testOverwitesWrongVersion() { + fun testOverwritesWrongVersion() { val (srv, url) = mockServer() val ccm = CoderCLIManager( @@ -250,6 +271,7 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("overwrite-cli").toString(), + fallbackOnCoderForSignatures = true ), ), ) @@ -261,7 +283,7 @@ internal class CoderCLIManagerTest { assertEquals("cli", ccm.localBinaryPath.toFile().readText()) assertEquals(0, ccm.localBinaryPath.toFile().lastModified()) - assertTrue(ccm.download()) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertNotEquals("cli", ccm.localBinaryPath.toFile().readText()) assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified()) @@ -279,14 +301,15 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("clobber-cli").toString(), + fallbackOnCoderForSignatures = true ), ) val ccm1 = CoderCLIManager(url1, settings) val ccm2 = CoderCLIManager(url2, settings) - assertTrue(ccm1.download()) - assertTrue(ccm2.download()) + assertTrue(runBlocking { ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) + assertTrue(runBlocking { ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) srv1.stop(0) srv2.stop(0) @@ -314,8 +337,12 @@ internal class CoderCLIManagerTest { fun testConfigureSSH() { val workspace = workspace("foo", agents = mapOf("agent1" to UUID.randomUUID().toString())) val workspace2 = workspace("bar", agents = mapOf("agent1" to UUID.randomUUID().toString())) - val betterWorkspace = workspace("foo", agents = mapOf("agent1" to UUID.randomUUID().toString()), ownerName = "bettertester") - val workspaceWithMultipleAgents = workspace("foo", agents = mapOf("agent1" to UUID.randomUUID().toString(), "agent2" to UUID.randomUUID().toString())) + val betterWorkspace = + workspace("foo", agents = mapOf("agent1" to UUID.randomUUID().toString()), ownerName = "bettertester") + val workspaceWithMultipleAgents = workspace( + "foo", + agents = mapOf("agent1" to UUID.randomUUID().toString(), "agent2" to UUID.randomUUID().toString()) + ) val extraConfig = listOf( @@ -331,7 +358,12 @@ internal class CoderCLIManagerTest { SSHTest(listOf(workspace), "existing-end", "replace-end", "no-blocks"), SSHTest(listOf(workspace), "existing-end-no-newline", "replace-end-no-newline", "no-blocks"), SSHTest(listOf(workspace), "existing-middle", "replace-middle", "no-blocks"), - SSHTest(listOf(workspace), "existing-middle-and-unrelated", "replace-middle-ignore-unrelated", "no-related-blocks"), + SSHTest( + listOf(workspace), + "existing-middle-and-unrelated", + "replace-middle-ignore-unrelated", + "no-related-blocks" + ), SSHTest(listOf(workspace), "existing-only", "replace-only", "blank"), SSHTest(listOf(workspace), "existing-start", "replace-start", "no-blocks"), SSHTest(listOf(workspace), "no-blocks", "append-no-blocks", "no-blocks"), @@ -463,7 +495,10 @@ internal class CoderCLIManagerTest { Path.of("src/test/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText() .replace(newlineRe, System.lineSeparator()) .replace("/tmp/coder-gateway/test.coder.invalid/config", escape(coderConfigPath.toString())) - .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString())) + .replace( + "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", + escape(ccm.localBinaryPath.toString()) + ) .let { conf -> if (it.sshLogDirectory != null) { conf.replace("/tmp/coder-gateway/test.coder.invalid/logs", it.sshLogDirectory.toString()) @@ -476,7 +511,10 @@ internal class CoderCLIManagerTest { Path.of("src/test/fixtures/inputs/").resolve(it.remove + ".conf").toFile().readText() .replace(newlineRe, System.lineSeparator()) .replace("/tmp/coder-gateway/test.coder.invalid/config", escape(coderConfigPath.toString())) - .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString())) + .replace( + "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", + escape(ccm.localBinaryPath.toString()) + ) .let { conf -> if (it.sshLogDirectory != null) { conf.replace("/tmp/coder-gateway/test.coder.invalid/logs", it.sshLogDirectory.toString()) @@ -552,7 +590,10 @@ internal class CoderCLIManagerTest { "new\nline", ) - val workspace = workspace("foo", agents = mapOf("agentid1" to UUID.randomUUID().toString(), "agentid2" to UUID.randomUUID().toString())) + val workspace = workspace( + "foo", + agents = mapOf("agentid1" to UUID.randomUUID().toString(), "agentid2" to UUID.randomUUID().toString()) + ) val withAgents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { workspace to it } @@ -730,8 +771,24 @@ internal class CoderCLIManagerTest { EnsureCLITest(null, null, "1.0.0", false, true, true, Result.DL_DATA), // Download to fallback. EnsureCLITest(null, null, "1.0.0", false, false, true, Result.NONE), // No download, error when used. EnsureCLITest("1.0.1", "1.0.1", "1.0.0", false, true, true, Result.DL_DATA), // Update fallback. - EnsureCLITest("1.0.1", "1.0.2", "1.0.0", false, false, true, Result.USE_BIN), // No update, use outdated. - EnsureCLITest(null, "1.0.2", "1.0.0", false, false, true, Result.USE_DATA), // No update, use outdated fallback. + EnsureCLITest( + "1.0.1", + "1.0.2", + "1.0.0", + false, + false, + true, + Result.USE_BIN + ), // No update, use outdated. + EnsureCLITest( + null, + "1.0.2", + "1.0.0", + false, + false, + true, + Result.USE_DATA + ), // No update, use outdated fallback. EnsureCLITest("1.0.0", null, "1.0.0", false, false, true, Result.USE_BIN), // Use existing. EnsureCLITest("1.0.1", "1.0.0", "1.0.0", false, false, true, Result.USE_DATA), // Use existing fallback. ) @@ -746,6 +803,7 @@ internal class CoderCLIManagerTest { enableBinaryDirectoryFallback = it.enableFallback, dataDirectory = tmpdir.resolve("ensure-data-dir").toString(), binaryDirectory = tmpdir.resolve("ensure-bin-dir").toString(), + fallbackOnCoderForSignatures = true ), ) @@ -777,34 +835,39 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(url, it.buildVersion, settings) }, + block = { runBlocking { ensureCLI(url, it.buildVersion, settings, noOpTextProgress) } } ) } + Result.NONE -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = runBlocking { ensureCLI(url, it.buildVersion, settings, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, block = { ccm.version() }, ) } + Result.DL_BIN -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = runBlocking { ensureCLI(url, it.buildVersion, settings, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } + Result.DL_DATA -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = runBlocking { ensureCLI(url, it.buildVersion, settings, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } + Result.USE_BIN -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = runBlocking { ensureCLI(url, it.buildVersion, settings, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } + Result.USE_DATA -> { - val ccm = ensureCLI(url, it.buildVersion, settings) + val ccm = runBlocking { ensureCLI(url, it.buildVersion, settings, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -838,11 +901,12 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("features").toString(), + fallbackOnCoderForSignatures = true ), binaryName = "coder.bat", ), ) - assertEquals(true, ccm.download()) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(it.second, ccm.features, "version: ${it.first}") srv.stop(0) @@ -850,7 +914,8 @@ internal class CoderCLIManagerTest { } companion object { - private val tmpdir: Path = Path.of(System.getProperty("java.io.tmpdir")).resolve("coder-gateway-test/cli-manager") + private val tmpdir: Path = + Path.of(System.getProperty("java.io.tmpdir")).resolve("coder-gateway-test/cli-manager") @JvmStatic @BeforeAll diff --git a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt index c3f69bd4..e98c1e78 100644 --- a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt @@ -3,14 +3,36 @@ package com.coder.gateway.settings import com.coder.gateway.util.OS import com.coder.gateway.util.getOS import com.coder.gateway.util.withPath +import org.junit.jupiter.api.Assertions import java.net.URL import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNotEquals internal class CoderSettingsTest { + private var originalOsName: String? = null + private var originalOsArch: String? = null + + private lateinit var store: CoderSettings + + @BeforeTest + fun setUp() { + originalOsName = System.getProperty("os.name") + originalOsArch = System.getProperty("os.arch") + store = CoderSettings(CoderSettingsState()) + System.setProperty("intellij.testFramework.rethrow.logged.errors", "false") + } + + @AfterTest + fun tearDown() { + System.setProperty("os.name", originalOsName) + System.setProperty("os.arch", originalOsArch) + } + @Test fun testExpands() { val state = CoderSettingsState() @@ -35,13 +57,13 @@ internal class CoderSettingsTest { CoderSettings( state, env = - Environment( - mapOf( - "LOCALAPPDATA" to "/tmp/coder-gateway-test/localappdata", - "HOME" to "/tmp/coder-gateway-test/home", - "XDG_DATA_HOME" to "/tmp/coder-gateway-test/xdg-data", + Environment( + mapOf( + "LOCALAPPDATA" to "/tmp/coder-gateway-test/localappdata", + "HOME" to "/tmp/coder-gateway-test/home", + "XDG_DATA_HOME" to "/tmp/coder-gateway-test/xdg-data", + ), ), - ), ) var expected = when (getOS()) { @@ -59,12 +81,12 @@ internal class CoderSettingsTest { CoderSettings( state, env = - Environment( - mapOf( - "XDG_DATA_HOME" to "", - "HOME" to "/tmp/coder-gateway-test/home", + Environment( + mapOf( + "XDG_DATA_HOME" to "", + "HOME" to "/tmp/coder-gateway-test/home", + ), ), - ), ) expected = "/tmp/coder-gateway-test/home/.local/share/coder-gateway/localhost" @@ -78,13 +100,13 @@ internal class CoderSettingsTest { CoderSettings( state, env = - Environment( - mapOf( - "LOCALAPPDATA" to "/ignore", - "HOME" to "/ignore", - "XDG_DATA_HOME" to "/ignore", + Environment( + mapOf( + "LOCALAPPDATA" to "/ignore", + "HOME" to "/ignore", + "XDG_DATA_HOME" to "/ignore", + ), ), - ), ) expected = "/tmp/coder-gateway-test/data-dir/localhost" assertEquals(Path.of(expected).toAbsolutePath(), settings.dataDir(url)) @@ -131,13 +153,13 @@ internal class CoderSettingsTest { CoderSettings( state, env = - Environment( - mapOf( - "APPDATA" to "/tmp/coder-gateway-test/cli-appdata", - "HOME" to "/tmp/coder-gateway-test/cli-home", - "XDG_CONFIG_HOME" to "/tmp/coder-gateway-test/cli-xdg-config", + Environment( + mapOf( + "APPDATA" to "/tmp/coder-gateway-test/cli-appdata", + "HOME" to "/tmp/coder-gateway-test/cli-home", + "XDG_CONFIG_HOME" to "/tmp/coder-gateway-test/cli-xdg-config", + ), ), - ), ) var expected = when (getOS()) { @@ -153,12 +175,12 @@ internal class CoderSettingsTest { CoderSettings( state, env = - Environment( - mapOf( - "XDG_CONFIG_HOME" to "", - "HOME" to "/tmp/coder-gateway-test/cli-home", + Environment( + mapOf( + "XDG_CONFIG_HOME" to "", + "HOME" to "/tmp/coder-gateway-test/cli-home", + ), ), - ), ) expected = "/tmp/coder-gateway-test/cli-home/.config/coderv2" assertEquals(Path.of(expected), settings.coderConfigDir) @@ -169,14 +191,14 @@ internal class CoderSettingsTest { CoderSettings( state, env = - Environment( - mapOf( - "CODER_CONFIG_DIR" to "/tmp/coder-gateway-test/coder-config-dir", - "APPDATA" to "/ignore", - "HOME" to "/ignore", - "XDG_CONFIG_HOME" to "/ignore", + Environment( + mapOf( + "CODER_CONFIG_DIR" to "/tmp/coder-gateway-test/coder-config-dir", + "APPDATA" to "/ignore", + "HOME" to "/ignore", + "XDG_CONFIG_HOME" to "/ignore", + ), ), - ), ) expected = "/tmp/coder-gateway-test/coder-config-dir" assertEquals(Path.of(expected), settings.coderConfigDir) @@ -402,4 +424,54 @@ internal class CoderSettingsTest { assertEquals(true, settings.ignoreSetupFailure) assertEquals("test ssh log directory", settings.sshLogDirectory) } + + + @Test + fun `Default CLI and signature for Windows AMD64`() = + assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") + + @Test + fun `Default CLI and signature for Windows ARM64`() = + assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.exe.asc") + + @Test + fun `Default CLI and signature for Linux AMD64`() = + assertBinaryAndSignature("Linux", "x86_64", "coder-linux-amd64", "coder-linux-amd64.asc") + + @Test + fun `Default CLI and signature for Linux ARM64`() = + assertBinaryAndSignature("Linux", "aarch64", "coder-linux-arm64", "coder-linux-arm64.asc") + + @Test + fun `Default CLI and signature for Linux ARMV7`() = + assertBinaryAndSignature("Linux", "armv7l", "coder-linux-armv7", "coder-linux-armv7.asc") + + @Test + fun `Default CLI and signature for Mac AMD64`() = + assertBinaryAndSignature("Mac OS X", "x86_64", "coder-darwin-amd64", "coder-darwin-amd64.asc") + + @Test + fun `Default CLI and signature for Mac ARM64`() = + assertBinaryAndSignature("Mac OS X", "aarch64", "coder-darwin-arm64", "coder-darwin-arm64.asc") + + @Test + fun `Default CLI and signature for unknown OS and Arch`() = + assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") + + @Test + fun `Default CLI and signature for unknown Arch fallback on Linux`() = + assertBinaryAndSignature("Linux", "mips64", "coder-linux-amd64", "coder-linux-amd64.asc") + + private fun assertBinaryAndSignature( + osName: String?, + arch: String?, + expectedBinary: String, + expectedSignature: String + ) { + if (osName == null) System.clearProperty("os.name") else System.setProperty("os.name", osName) + if (arch == null) System.clearProperty("os.arch") else System.setProperty("os.arch", arch) + + Assertions.assertEquals(expectedBinary, store.defaultCliBinaryNameByOsAndArch) + Assertions.assertEquals(expectedSignature, store.defaultSignatureNameByOsAndArch) + } } diff --git a/src/test/kotlin/com/coder/gateway/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/gateway/util/URLExtensionsTest.kt index 2feea340..4c286da0 100644 --- a/src/test/kotlin/com/coder/gateway/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/gateway/util/URLExtensionsTest.kt @@ -60,4 +60,65 @@ internal class URLExtensionsTest { ) } } + @Test + fun `valid http URL should return Valid`() { + val result = "http://coder.com".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `valid https URL with path and query should return Valid`() { + val result = "https://coder.com/bin/coder-linux-amd64?query=1".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `relative URL should return Invalid with appropriate message`() { + val url = "/bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("$url is relative, it must be absolute"), + result + ) + } + + @Test + fun `opaque URI like mailto should return Invalid`() { + val url = "mailto:user@coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("$url is opaque, instead of hierarchical"), + result + ) + } + + @Test + fun `unsupported scheme like ftp should return Invalid`() { + val url = "ftp://coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("Scheme for $url must be either http or https"), + result + ) + } + + @Test + fun `http URL with missing authority should return Invalid`() { + val url = "http:///bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("$url does not have a hostname"), + result + ) + } + + @Test + fun `malformed URI should return Invalid with parsing error message`() { + val url = "http://[invalid-uri]" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("Malformed IPv6 address at index 8: $url"), + result + ) + } } From 274ee1f6f001e9ef80cf1a91c17bff2b87491efd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 00:32:48 +0300 Subject: [PATCH 07/19] Changelog update - `v2.22.0` (#563) * Changelog update - v2.22.0 * chore: empty commit to trigger CI --------- Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2dbab4b..b43a9f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.22.0 - 2025-07-25 + ### Added - support for checking if CLI is signed From 0773310775f72e3e9caaec2859e9185eec893574 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 23:12:00 +0300 Subject: [PATCH 08/19] impl: add support for disabling CLI signature verification (#564) * impl: add new configurable option to disable CLI signature verification These options are configurable from the Settings page there is no available shortcut on the main plugin page to discourage the quick disable of CLI verification * impl: hide configurable fallback if signature verification is disabled The main plugin screen has a quick shortcut for setting whether the user wants to fallback on releases.coder.com for signatures if they are not provided by the main deployment. This checkbox should not be visible if the user wants to disable signature verification altogether. * impl: skip signature validation Signature validation is skipped if the user configured the `disableSignatureVerification` to true. * chore: update changelog * chore: next version is 2.22.1 * doc: developer facing documentation for CLI signature verification * chore: fix UTs --- CHANGELOG.md | 4 ++ CONTRIBUTING.md | 64 +++++++++++++++++++ gradle.properties | 2 +- .../gateway/CoderSettingsConfigurable.kt | 48 ++++++++------ .../com/coder/gateway/cli/CoderCLIManager.kt | 5 ++ .../coder/gateway/settings/CoderSettings.kt | 17 ++++- .../views/steps/CoderWorkspacesStepView.kt | 2 +- .../messages/CoderGatewayBundle.properties | 4 +- .../coder/gateway/cli/CoderCLIManagerTest.kt | 2 +- .../coder/gateway/sdk/CoderRestClientTest.kt | 58 +++++++++++------ .../gateway/settings/CoderSettingsTest.kt | 4 +- 11 files changed, 161 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b43a9f4f..3c25cd70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Added + +- support for skipping CLI signature verification + ## 2.22.0 - 2025-07-25 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f79e3d82..d88e8d1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,70 @@ There are three ways to get into a workspace: Currently the first two will configure SSH but the third does not yet. +## GPG Signature Verification + +The Coder Gateway plugin starting with version *2.22.0* implements a comprehensive GPG signature verification system to +ensure the authenticity and integrity of downloaded Coder CLI binaries. This security feature helps protect users from +running potentially malicious or tampered binaries. + +### How It Works + +1. **Binary Download**: When connecting to a Coder deployment, the plugin downloads the appropriate Coder CLI binary for + the user's operating system and architecture from the deployment's `/bin/` endpoint. + +2. **Signature Download**: After downloading the binary, the plugin attempts to download the corresponding `.asc` + signature file from the same location. The signature file is named according to the binary (e.g., + `coder-linux-amd64.asc` for `coder-linux-amd64`). + +3. **Fallback Signature Sources**: If the signature is not available from the deployment, the plugin can optionally fall + back to downloading signatures from `releases.coder.com`. This is controlled by the `fallbackOnCoderForSignatures` + setting. + +4. **GPG Verification**: The plugin uses the BouncyCastle library shipped with Gateway app to verify the detached GPG + signature against the downloaded binary using Coder's trusted public key. + +5. **User Interaction**: If signature verification fails or signatures are unavailable, the plugin presents security + warnings + to users, allowing them to accept the risk and continue or abort the operation. + +### Verification Process + +The verification process involves several components: + +- **`GPGVerifier`**: Handles the core GPG signature verification logic using BouncyCastle +- **`VerificationResult`**: Represents the outcome of verification (Valid, Invalid, Failed, SignatureNotFound) +- **`CoderDownloadService`**: Manages downloading both binaries and their signatures +- **`CoderCLIManager`**: Orchestrates the download and verification workflow + +### Configuration Options + +Users can control signature verification behavior through plugin settings: + +- **`disableSignatureVerification`**: When enabled, skips all signature verification. This is useful for clients running + custom CLI builds, or + customers with old deployment versions that don't have a signature published on `releases.coder.com`. +- **`fallbackOnCoderForSignatures`**: When enabled, allows downloading signatures from `releases.coder.com` if not + available from the deployment + +### Security Considerations + +- The plugin embeds Coder's trusted public key in the plugin resources +- Verification uses detached signatures, which are more secure than attached signatures +- Users are warned about security risks when verification fails +- The system gracefully handles cases where signatures are unavailable +- All verification failures are logged for debugging purposes + +### Error Handling + +The system handles various failure scenarios: + +- **Missing signatures**: Prompts user to accept risk or abort +- **Invalid signatures**: Warns user about potential tampering and prompts user to accept risk or abort +- **Verification failures**: Prompts user to accept risk or abort + +This signature verification system ensures that users can trust the Coder CLI binaries they download through the plugin, +protecting against supply chain attacks and ensuring binary integrity. + ## Development To manually install a local build: diff --git a/gradle.properties b/gradle.properties index b3085324..bcc3a36b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.22.0 +pluginVersion=2.22.1 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 2032dc69..64a140b4 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -8,13 +8,17 @@ import com.intellij.openapi.components.service import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.ui.DialogPanel import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.Cell import com.intellij.ui.dsl.builder.RowLayout import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected import com.intellij.ui.layout.ValidationInfoBuilder +import com.intellij.ui.layout.not import java.net.URL import java.nio.file.Path @@ -60,22 +64,27 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { .bindText(state::binaryDirectory) .comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment")) }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.title")) - .bindSelected(state::enableBinaryDirectoryFallback) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) - .bindSelected(state::fallbackOnCoderForSignatures) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), - ) - }.layout(RowLayout.PARENT_GRID) + group { + lateinit var signatureVerificationCheckBox: Cell + row { + cell() // For alignment. + signatureVerificationCheckBox = + checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.title")) + .bindSelected(state::disableSignatureVerification) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) + .bindSelected(state::fallbackOnCoderForSignatures) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), + ) + }.visibleIf(signatureVerificationCheckBox.selected.not()) + .layout(RowLayout.PARENT_GRID) + } row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) { textField().resizableColumn().align(AlignX.FILL) .bindText(state::headerCommand) @@ -122,7 +131,10 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { textArea().resizableColumn().align(AlignX.FILL) .bindText(state::sshConfigOptions) .comment( - CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.comment", CODER_SSH_CONFIG_OPTIONS), + CoderGatewayBundle.message( + "gateway.connector.settings.ssh-config-options.comment", + CODER_SSH_CONFIG_OPTIONS + ), ) }.layout(RowLayout.PARENT_GRID) row(CoderGatewayBundle.message("gateway.connector.settings.setup-command.title")) { @@ -162,7 +174,7 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { .bindText(state::defaultIde) .comment( "The default IDE version to display in the IDE selection dropdown. " + - "Example format: CL 2023.3.6 233.15619.8", + "Example format: CL 2023.3.6 233.15619.8", ) } row(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.heading")) { diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index c916450e..e06b8702 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -174,6 +174,11 @@ class CoderCLIManager( else -> result as DownloadResult.Downloaded } } + if (settings.disableSignatureVerification) { + downloader.commit() + logger.info("Skipping over CLI signature verification, it is disabled by the user") + return true + } var signatureResult = withContext(Dispatchers.IO) { downloader.downloadSignature(showTextProgress) diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index 31d64d9c..aa517746 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -65,7 +65,12 @@ open class CoderSettingsState( open var enableBinaryDirectoryFallback: Boolean = false, /** - * Controls whether we fall back release.coder.com + * Controls whether we verify the cli signature + */ + open var disableSignatureVerification: Boolean = false, + + /** + * Controls whether we fall back release.coder.com if signature validation is enabled */ open var fallbackOnCoderForSignatures: Boolean = false, @@ -109,7 +114,7 @@ open class CoderSettingsState( // Default version of IDE to display in IDE selection dropdown open var defaultIde: String = "", // Whether to check for IDE updates. - open var checkIDEUpdates: Boolean = true, + open var checkIDEUpdates: Boolean = true ) /** @@ -137,7 +142,7 @@ open class CoderSettings( // Overrides the default environment (for tests). private val env: Environment = Environment(), // Overrides the default binary name (for tests). - private val binaryName: String? = null, + private val binaryName: String? = null ) { val tls = CoderTLSSettings(state) @@ -160,6 +165,12 @@ open class CoderSettings( val enableBinaryDirectoryFallback: Boolean get() = state.enableBinaryDirectoryFallback + /** + * Controls whether we verify the cli signature + */ + val disableSignatureVerification: Boolean + get() = state.disableSignatureVerification + /** * Controls whether we fall back release.coder.com */ diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 51a7df4b..31304d63 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -306,7 +306,7 @@ class CoderWorkspacesStepView : CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), ) - }.layout(RowLayout.PARENT_GRID) + }.visible(state.disableSignatureVerification.not()).layout(RowLayout.PARENT_GRID) row { scrollCell( toolbar.createPanel().apply { diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 3364e6f3..7420b576 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -75,10 +75,10 @@ gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to d gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \ box will allow the plugin to fall back to the data directory when the CLI \ directory is not writable. - +gateway.connector.settings.disable-signature-validation.title=Disable Coder CLI signature verification +gateway.connector.settings.disable-signature-validation.comment=Useful if you run an unsigned fork for the binary gateway.connector.settings.fallback-on-coder-for-signatures.title=Fall back on releases.coder.com for signatures gateway.connector.settings.fallback-on-coder-for-signatures.comment=Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment - gateway.connector.settings.header-command.title=Header command gateway.connector.settings.header-command.comment=An external command that \ outputs additional HTTP headers added to all requests. The command must \ diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index f0d82769..d83690b7 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -124,7 +124,7 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("cli-data-dir").toString(), - binaryDirectory = tmpdir.resolve("cli-bin-dir").toString(), + binaryDirectory = tmpdir.resolve("cli-bin-dir").toString() ), ) val url = URL("http://localhost") diff --git a/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt index 877408f5..4af973a4 100644 --- a/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt @@ -229,31 +229,44 @@ class CoderRestClientTest { // Nothing, so no resources. emptyList(), // One workspace with an agent, but no resources. - listOf(TestWorkspace(DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")))), + listOf( + TestWorkspace( + DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") + ) + ) + ), // One workspace with an agent and resources that do not match the agent. listOf( TestWorkspace( - workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), - resources = - listOf( - DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), - DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + workspace = DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") ), + resources = + listOf( + DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), + DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + ), ), ), // Multiple workspaces but only one has resources. listOf( TestWorkspace( - workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), + workspace = DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") + ), resources = emptyList(), ), TestWorkspace( workspace = DataGen.workspace("ws2"), resources = - listOf( - DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), - DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), - ), + listOf( + DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), + DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + ), ), TestWorkspace( workspace = DataGen.workspace("ws3"), @@ -272,7 +285,8 @@ class CoderRestClientTest { val matches = resourceEndpoint.find(exchange.requestURI.path) if (matches != null) { val templateVersionId = UUID.fromString(matches.destructured.toList()[0]) - val ws = workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } + val ws = + workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } if (ws != null) { val body = moshi.adapter>( @@ -326,7 +340,8 @@ class CoderRestClientTest { val buildMatch = buildEndpoint.find(exchange.requestURI.path) if (buildMatch != null) { val workspaceId = UUID.fromString(buildMatch.destructured.toList()[0]) - val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java).fromJson(exchange.requestBody.source().buffer()) + val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java) + .fromJson(exchange.requestBody.source().buffer()) if (json == null) { val response = Response("No body", "No body for create workspace build request") val body = moshi.adapter(Response::class.java).toJson(response).toByteArray() @@ -396,8 +411,8 @@ class CoderRestClientTest { CoderSettings( CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), - tlsAlternateHostname = "localhost", - ), + tlsAlternateHostname = "localhost" + ) ) val user = DataGen.user() val (srv, url) = mockTLSServer("self-signed") @@ -422,8 +437,8 @@ class CoderRestClientTest { CoderSettings( CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), - tlsAlternateHostname = "fake.example.com", - ), + tlsAlternateHostname = "fake.example.com" + ) ) val (srv, url) = mockTLSServer("self-signed") val client = CoderRestClient(URL(url), "token", settings) @@ -441,8 +456,8 @@ class CoderRestClientTest { val settings = CoderSettings( CoderSettingsState( - tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), - ), + tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString() + ) ) val (srv, url) = mockTLSServer("no-signing") val client = CoderRestClient(URL(url), "token", settings) @@ -461,7 +476,7 @@ class CoderRestClientTest { CoderSettings( CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "chain-root.crt").toString(), - ), + ) ) val user = DataGen.user() val (srv, url) = mockTLSServer("chain") @@ -505,7 +520,8 @@ class CoderRestClientTest { "bar", true, object : ProxySelector() { - override fun select(uri: URI): List = listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) + override fun select(uri: URI): List = + listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) override fun connectFailed( uri: URI, diff --git a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt index e98c1e78..71447db5 100644 --- a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt @@ -63,7 +63,7 @@ internal class CoderSettingsTest { "HOME" to "/tmp/coder-gateway-test/home", "XDG_DATA_HOME" to "/tmp/coder-gateway-test/xdg-data", ), - ), + ) ) var expected = when (getOS()) { @@ -408,7 +408,7 @@ internal class CoderSettingsTest { disableAutostart = getOS() != OS.MAC, setupCommand = "test setup", ignoreSetupFailure = true, - sshLogDirectory = "test ssh log directory", + sshLogDirectory = "test ssh log directory" ), ) From 35f4ef9f1010b692198a7108cb11c15bfc8a71e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 23:57:43 +0300 Subject: [PATCH 09/19] Changelog update - `v2.22.1` (#565) * Changelog update - v2.22.1 * chore: trigger CI --------- Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c25cd70..7ff76d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.22.1 - 2025-07-30 + ### Added - support for skipping CLI signature verification From 66e470f4f841d88bb27e6519283e49f518e18142 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 8 Sep 2025 19:13:09 +0300 Subject: [PATCH 10/19] fix: don't create new api keys each time we do workspace polling (#568) * fix: don't create new api keys each time we do workspace polling The default behavior for `coder login --token ` is to: - use the provided token temporarily to authenticate the login process - generate a new session token and stores that for future CLI use - the original token provided is not stored or reused The Coder `Recent projects` view polls every 5 seconds for workspaces from multiple Coder deployment. The polling process also involves a call to the `cli.login`. The cli is later used start workspaces if user clicks on a project for which the workspace is stopped. Instead of generating a new token each time we login we can use the `coder login --use-token-as-session --token ` which: - uses the provided token directly as the session token - stores the original token for future CLI commands - no new token is generated * refactor: only login the cli when starting workspaces The Coder `Recent projects` view polls every 5 seconds for workspaces from multiple Coder deployment. The polling process also involves a call to the `cli.login`. The cli is later used to start workspaces if a user clicks on a project for which the workspace is stopped. The login can be called on demand, only when a "recent" project is stopped and the user wants to start it. This commit reduces a lot of overhead associated with spawning cli commands every 5 seconds. * chore: next version is 2.22.2 --- CHANGELOG.md | 4 + gradle.properties | 2 +- .../com/coder/gateway/cli/CoderCLIManager.kt | 1 + ...erGatewayRecentWorkspaceConnectionsView.kt | 107 +++++++++++------- 4 files changed, 69 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff76d89..b6e5d375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Fixed + +- api keys are no longer created each time workspaces are polled + ## 2.22.1 - 2025-07-30 ### Added diff --git a/gradle.properties b/gradle.properties index bcc3a36b..f89b3b89 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.22.1 +pluginVersion=2.22.2 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index e06b8702..cfa7deef 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -288,6 +288,7 @@ class CoderCLIManager( return exec( "login", deploymentURL.toString(), + "--use-token-as-session", "--token", token, "--global-config", diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index ded8edfa..c679c451 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -5,7 +5,6 @@ package com.coder.gateway.views import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.CoderGatewayConstants import com.coder.gateway.CoderRemoteConnectionHandle -import com.coder.gateway.cli.CoderCLIManager import com.coder.gateway.cli.ensureCLI import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.WorkspaceAgentListModel @@ -75,8 +74,6 @@ data class DeploymentInfo( var items: List? = null, // Null if there have not been any errors yet. var error: String? = null, - // Null if unable to ensure the CLI is downloaded. - var cli: CoderCLIManager? = null, ) class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : @@ -178,13 +175,18 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: val me = deployment?.client?.me?.username val workspaceWithAgent = deployment?.items?.firstOrNull { it.workspace.ownerName + "/" + it.workspace.name == workspaceName || - (it.workspace.ownerName == me && it.workspace.name == workspaceName) + (it.workspace.ownerName == me && it.workspace.name == workspaceName) } val status = if (deploymentError != null) { Triple(UIUtil.getErrorForeground(), deploymentError, UIUtil.getBalloonErrorIcon()) } else if (workspaceWithAgent != null) { - val inLoadingState = listOf(WorkspaceStatus.STARTING, WorkspaceStatus.CANCELING, WorkspaceStatus.DELETING, WorkspaceStatus.STOPPING).contains(workspaceWithAgent.workspace.latestBuild.status) + val inLoadingState = listOf( + WorkspaceStatus.STARTING, + WorkspaceStatus.CANCELING, + WorkspaceStatus.DELETING, + WorkspaceStatus.STOPPING + ).contains(workspaceWithAgent.workspace.latestBuild.status) Triple( workspaceWithAgent.status.statusColor(), @@ -196,7 +198,11 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: }, ) } else { - Triple(UIUtil.getContextHelpForeground(), "Querying workspace status...", AnimatedIcon.Default()) + Triple( + UIUtil.getContextHelpForeground(), + "Querying workspace status...", + AnimatedIcon.Default() + ) } val gap = if (top) { @@ -216,7 +222,13 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: label("").resizableColumn().align(AlignX.FILL) }.topGap(gap) - val enableLinks = listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED, WorkspaceStatus.STARTING, WorkspaceStatus.RUNNING).contains(workspaceWithAgent?.workspace?.latestBuild?.status) + val enableLinks = listOf( + WorkspaceStatus.STOPPED, + WorkspaceStatus.CANCELED, + WorkspaceStatus.FAILED, + WorkspaceStatus.STARTING, + WorkspaceStatus.RUNNING + ).contains(workspaceWithAgent?.workspace?.latestBuild?.status) // We only display an API error on the first workspace rather than duplicating it on each workspace. if (deploymentError == null || showError) { @@ -236,9 +248,29 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: if (enableLinks) { cell( ActionLink(workspaceProjectIDE.projectPathDisplay) { - withoutNull(deployment?.cli, workspaceWithAgent?.workspace) { cli, workspace -> + withoutNull( + deployment?.client, + workspaceWithAgent?.workspace + ) { client, workspace -> CoderRemoteConnectionHandle().connect { - if (listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED).contains(workspace.latestBuild.status)) { + if (listOf( + WorkspaceStatus.STOPPED, + WorkspaceStatus.CANCELED, + WorkspaceStatus.FAILED + ).contains(workspace.latestBuild.status) + ) { + val cli = ensureCLI( + deploymentURL.toURL(), + client.buildInfo().version, + settings, + ) + // We only need to log the cli in if we have token-based auth. + // Otherwise, we assume it is set up in the same way the plugin + // is with mTLS. + if (client.token != null) { + cli.login(client.token) + } + cli.startWorkspace(workspace.ownerName, workspace.name) } workspaceProjectIDE @@ -289,33 +321,34 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: * name, or just `workspace`, if the connection predates when we added owner * information, in which case it belongs to the current user. */ - private fun getConnectionsByDeployment(filter: Boolean): Map>> = recentConnectionsService.getAllRecentConnections() - // Validate and parse connections. - .mapNotNull { - try { - it.toWorkspaceProjectIDE() - } catch (e: Exception) { - logger.warn("Removing invalid recent connection $it", e) - recentConnectionsService.removeConnection(it) - null + private fun getConnectionsByDeployment(filter: Boolean): Map>> = + recentConnectionsService.getAllRecentConnections() + // Validate and parse connections. + .mapNotNull { + try { + it.toWorkspaceProjectIDE() + } catch (e: Exception) { + logger.warn("Removing invalid recent connection $it", e) + recentConnectionsService.removeConnection(it) + null + } + } + .filter { !filter || matchesFilter(it) } + // Group by the deployment. + .groupBy { it.deploymentURL.toString() } + // Group the connections in each deployment by workspace. + .mapValues { (_, connections) -> + connections + .groupBy { it.name.split(".", limit = 2).first() } } - } - .filter { !filter || matchesFilter(it) } - // Group by the deployment. - .groupBy { it.deploymentURL.toString() } - // Group the connections in each deployment by workspace. - .mapValues { (_, connections) -> - connections - .groupBy { it.name.split(".", limit = 2).first() } - } /** * Return true if the connection matches the current filter. */ private fun matchesFilter(connection: WorkspaceProjectIDE): Boolean = filterString.let { it.isNullOrBlank() || - connection.hostname.lowercase(Locale.getDefault()).contains(it) || - connection.projectPath.lowercase(Locale.getDefault()).contains(it) + connection.hostname.lowercase(Locale.getDefault()).contains(it) || + connection.projectPath.lowercase(Locale.getDefault()).contains(it) } /** @@ -362,19 +395,6 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: throw Exception("Unable to make request; token was not found in CLI config.") } - val cli = ensureCLI( - deploymentURL.toURL(), - client.buildInfo().version, - settings, - ) - - // We only need to log the cli in if we have token-based auth. - // Otherwise, we assume it is set up in the same way the plugin - // is with mTLS. - if (client.token != null) { - cli.login(client.token) - } - // This is purely to populate the current user, which is // used to match workspaces that were not recorded with owner // information. @@ -386,7 +406,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: connectionsByWorkspace.forEach { (name, connections) -> if (items.firstOrNull { it.workspace.ownerName + "/" + it.workspace.name == name || - (it.workspace.ownerName == me && it.workspace.name == name) + (it.workspace.ownerName == me && it.workspace.name == name) } == null ) { logger.info("Removing recent connections for deleted workspace $name (found ${connections.size})") @@ -395,7 +415,6 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: } deployment.client = client - deployment.cli = cli deployment.items = items deployment.error = null } catch (e: Exception) { From a97812f36071b96b49c8807b6fad51188fa03594 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:44:14 +0300 Subject: [PATCH 11/19] chore: bump actions/setup-java from 4 to 5 (#573) Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Faur Ioan-Aurel --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b00555b..5fb6c0cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: steps: - uses: actions/checkout@v4.2.2 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 @@ -60,7 +60,7 @@ jobs: # Setup Java 11 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e8da9b5..87c52290 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: # Setup Java 17 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 From 3031c17aa1a2513b5f6586c4fff8ea80c25a5a06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 22:07:49 +0300 Subject: [PATCH 12/19] chore: bump actions/checkout from 4.2.2 to 5.0.0 (#571) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Faur Ioan-Aurel --- .github/workflows/build.yml | 6 +++--- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fb6c0cc..c31f44fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 - uses: actions/setup-java@v5 with: @@ -56,7 +56,7 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 # Setup Java 11 environment for the next steps - name: Setup Java @@ -141,7 +141,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 # Remove old release drafts by using the curl request for the available releases with draft flag - name: Remove Old Release Drafts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87c52290..e67f023e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: ref: ${{ github.event.release.tag_name }} From f1a40c0b8e09eff8a8dc254e8ca6b91391488e5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 23:21:51 +0300 Subject: [PATCH 13/19] Changelog update - `v2.22.2` (#575) * Changelog update - v2.22.2 * chore: trigger GH build --------- Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e5d375..76db3038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.22.2 - 2025-09-08 + ### Fixed - api keys are no longer created each time workspaces are polled From b7b609d1b2a5691be4a28c9201bc0245a013e07c Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 19 Sep 2025 10:33:24 +0300 Subject: [PATCH 14/19] fix: relaxed SNI hostname resolution (#579) * fix: relaxed SNI hostname resolution When establishing TLS connections, SNI resolution may fail if the configured altHostname contains `_` or any other characters not allowed by domain name standards (i.e. letters, digits and hyphens). This change introduces a relaxed SNI resolution strategy which ignores the LDH rules completely. Because this change goes hand in hand with auth. via certificates, I was able to reproduce the issue only via UTs. At this point the official Coder releases supports only auth. via API keys. - fixes #577 * chore: next version 2.22.3 * chore: remove leftover debug liness --- CHANGELOG.md | 4 + gradle.properties | 2 +- src/main/kotlin/com/coder/gateway/util/TLS.kt | 19 +- .../util/AlternateNameSSLSocketFactoryTest.kt | 237 +++++++++++++++++ .../gateway/util/CoderHostnameVerifierTest.kt | 238 ++++++++++++++++++ 5 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 src/test/kotlin/com/coder/gateway/util/AlternateNameSSLSocketFactoryTest.kt create mode 100644 src/test/kotlin/com/coder/gateway/util/CoderHostnameVerifierTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76db3038..bfa13a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Fixed + +- relaxed SNI hostname resolution + ## 2.22.2 - 2025-09-08 ### Fixed diff --git a/gradle.properties b/gradle.properties index f89b3b89..edaf8108 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.22.2 +pluginVersion=2.22.3 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 diff --git a/src/main/kotlin/com/coder/gateway/util/TLS.kt b/src/main/kotlin/com/coder/gateway/util/TLS.kt index e9c438e9..7d945f53 100644 --- a/src/main/kotlin/com/coder/gateway/util/TLS.kt +++ b/src/main/kotlin/com/coder/gateway/util/TLS.kt @@ -5,8 +5,10 @@ import okhttp3.internal.tls.OkHostnameVerifier import org.slf4j.LoggerFactory import java.io.File import java.io.FileInputStream +import java.net.IDN import java.net.InetAddress import java.net.Socket +import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.KeyStore import java.security.cert.CertificateException @@ -19,11 +21,12 @@ import java.util.Locale import javax.net.ssl.HostnameVerifier import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SNIHostName +import javax.net.ssl.SNIServerName import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.StandardConstants import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -112,7 +115,8 @@ fun coderTrustManagers(tlsCAPath: String): Array { return trustManagerFactory.trustManagers.map { MergedSystemTrustManger(it as X509TrustManager) }.toTypedArray() } -class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : SSLSocketFactory() { +class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : + SSLSocketFactory() { override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites @@ -176,11 +180,17 @@ class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, priv private fun customizeSocket(socket: SSLSocket) { val params = socket.sslParameters - params.serverNames = listOf(SNIHostName(alternateName)) + + params.serverNames = listOf(RelaxedSNIHostname(alternateName)) socket.sslParameters = params } } +private class RelaxedSNIHostname(hostname: String) : SNIServerName( + StandardConstants.SNI_HOST_NAME, + IDN.toASCII(hostname, 0).toByteArray(StandardCharsets.UTF_8) +) + class CoderHostnameVerifier(private val alternateName: String) : HostnameVerifier { private val logger = LoggerFactory.getLogger(javaClass) @@ -244,5 +254,6 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) : } } - override fun getAcceptedIssuers(): Array = otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers + override fun getAcceptedIssuers(): Array = + otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers } diff --git a/src/test/kotlin/com/coder/gateway/util/AlternateNameSSLSocketFactoryTest.kt b/src/test/kotlin/com/coder/gateway/util/AlternateNameSSLSocketFactoryTest.kt new file mode 100644 index 00000000..e16b6c57 --- /dev/null +++ b/src/test/kotlin/com/coder/gateway/util/AlternateNameSSLSocketFactoryTest.kt @@ -0,0 +1,237 @@ +package com.coder.gateway.util + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.net.InetAddress +import java.net.Socket +import javax.net.ssl.SSLParameters +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + + +class AlternateNameSSLSocketFactoryTest { + + @Test + fun `createSocket with no parameters should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket() + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with host and port should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket("original.com", 443) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket("original.com", 443) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with host port and local address should customize socket`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val localHost = mockk() + + every { mockFactory.createSocket("original.com", 443, localHost, 8080) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket("original.com", 443, localHost, 8080) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with InetAddress should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val address = mockk() + + every { mockFactory.createSocket(address, 443) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(address, 443) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with InetAddress and local address should customize socket`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val address = mockk() + val localAddress = mockk() + + every { mockFactory.createSocket(address, 443, localAddress, 8080) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(address, 443, localAddress, 8080) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with existing socket should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSSLSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val existingSocket = mockk() + + every { mockFactory.createSocket(existingSocket, "original.com", 443, true) } returns mockSSLSocket + every { mockSSLSocket.sslParameters } returns mockParams + every { mockSSLSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(existingSocket, "original.com", 443, true) + + // Then + verify { mockSSLSocket.sslParameters = any() } + assertSame(mockSSLSocket, result) + } + + @Test + fun `customizeSocket should set SNI hostname to alternate name for valid hostname`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "valid-hostname.example.com") + + // When & Then - This should work without throwing an exception + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + } + + @Test + fun `customizeSocket should NOT throw IllegalArgumentException for hostname with underscore`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "non_compliant_hostname.example.com") + + // When & Then - This should work without throwing an exception + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + assertEquals(0, mockSocket.sslParameters.serverNames.size) + } + + @Test + fun `createSocket should work with valid international domain names`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "test-server.example.com") + + // When & Then - This should work as hyphens are valid + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + } + + private fun createMockSSLSocketFactory(): SSLSocketFactory { + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + // Setup default behavior + every { mockFactory.defaultCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384") + every { mockFactory.supportedCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256") + + // Make all createSocket methods return our mock socket + every { mockFactory.createSocket() } returns mockSocket + every { mockFactory.createSocket(any(), any()) } returns mockSocket + every { mockFactory.createSocket(any(), any(), any(), any()) } returns mockSocket + every { mockFactory.createSocket(any(), any()) } returns mockSocket + every { + mockFactory.createSocket( + any(), + any(), + any(), + any() + ) + } returns mockSocket + every { mockFactory.createSocket(any(), any(), any(), any()) } returns mockSocket + + // Setup SSL parameters + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + return mockFactory + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/gateway/util/CoderHostnameVerifierTest.kt b/src/test/kotlin/com/coder/gateway/util/CoderHostnameVerifierTest.kt new file mode 100644 index 00000000..fca72a4c --- /dev/null +++ b/src/test/kotlin/com/coder/gateway/util/CoderHostnameVerifierTest.kt @@ -0,0 +1,238 @@ +package com.coder.gateway.util + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.Logger +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import javax.net.ssl.SSLSession +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderHostnameVerifierTest { + + private lateinit var sslSession: SSLSession + private lateinit var x509Certificate: X509Certificate + private lateinit var logger: Logger + private lateinit var verifier: CoderHostnameVerifier + + @BeforeEach + fun setUp() { + sslSession = mockk() + x509Certificate = mockk() + logger = mockk(relaxed = true) + } + + @Test + fun `should return false when no certificates are present`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + every { sslSession.peerCertificates } returns null + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result) + } + + @Test + fun `should return false when certificates array is empty`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + every { sslSession.peerCertificates } returns arrayOf() + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result) + } + + @Test + fun `should return true when SAN contains matching alternate name with underscore`() { + // Given + val alternateNameWithUnderscore = "test_server.internal.com" + verifier = CoderHostnameVerifier(alternateNameWithUnderscore) + + // Mock certificate with SAN containing underscore + val sanEntries = listOf( + listOf(2, "example.com"), // Standard DNS name + listOf(2, "test_server.internal.com"), // SAN with underscore + listOf(2, "api.example.com") // Another DNS name + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should return true when SAN contains matching alternate name with underscore") + } + + @Test + fun `should return false when SAN does not contain matching alternate name`() { + // Given + verifier = CoderHostnameVerifier("missing_host.example.com") + + // Mock certificate without matching SAN + val sanEntries = listOf( + listOf(2, "example.com"), + listOf(2, "api.example.com"), + listOf(2, "different_host.example.com") + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when SAN does not contain matching alternate name") + } + + @Test + fun `should ignore non-DNS SAN entries`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + // Mock certificate with various SAN types + val sanEntries = listOf( + listOf(1, "user@example.com"), // Email (type 1) + listOf(6, "http://example.com"), // URI (type 6) + listOf(7, "192.168.1.1"), // IP Address (type 7) + listOf(2, "test_host.example.com") // DNS Name (type 2) - this should match + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should ignore non-DNS SAN entries and find the matching DNS entry") + } + + @Test + fun `should return false when certificate has no SAN extension`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns null + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when certificate has no SAN extension") + } + + @Test + fun `should handle multiple certificates and find match in second certificate`() { + // Given + verifier = CoderHostnameVerifier("api_server.internal.com") + + val cert1Mock = mockk() + val cert2Mock = mockk() + + // First certificate has no matching SAN + val sanEntries1 = listOf( + listOf(2, "example.com"), + listOf(2, "www.example.com") + ) + + // Second certificate has matching SAN with underscore + val sanEntries2 = listOf( + listOf(2, "internal.com"), + listOf(2, "api_server.internal.com") + ) + + every { sslSession.peerCertificates } returns arrayOf(cert1Mock, cert2Mock) + every { cert1Mock.subjectAlternativeNames } returns sanEntries1 + every { cert2Mock.subjectAlternativeNames } returns sanEntries2 + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should find match in second certificate") + } + + @Test + fun `should handle non-X509 certificates gracefully`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + val nonX509Cert = mockk() // Not an X509Certificate + every { sslSession.peerCertificates } returns arrayOf(nonX509Cert, x509Certificate) + + val sanEntries = listOf( + listOf(2, "test_host.example.com") + ) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should skip non-X509 certificates and process X509 certificates") + } + + @Test + fun `should reproduce the underscore bug scenario`() { + // Given - This test reproduces the exact scenario from the bug report + val problematicHostname = "coder_instance.dev.company.com" + verifier = CoderHostnameVerifier(problematicHostname) + + // Mock a certificate that would be valid but contains underscore in SAN + val sanEntries = listOf( + listOf(2, "dev.company.com"), + listOf(2, "coder_instance.dev.company.com"), // This contains underscore + listOf(2, "*.dev.company.com") + ) + + every { x509Certificate.subjectAlternativeNames } returns sanEntries + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + + // When + val result = verifier.verify("dev.company.com", sslSession) + + // Then + assertTrue(result, "Should successfully verify hostname with underscore in SAN") + + // Additional verification that the problematic hostname would be found + val foundHostnames = mutableListOf() + sanEntries.forEach { entry -> + if (entry[0] == 2) { // DNS name type + foundHostnames.add(entry[1] as String) + } + } + + assertTrue( + foundHostnames.any { it.equals(problematicHostname, ignoreCase = true) }, + "Certificate should contain the problematic hostname with underscore" + ) + } + + @Test + fun `should handle edge case with empty SAN list`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns emptyList() + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when SAN list is empty") + } +} \ No newline at end of file From b1a32ff39d3121167f39cf3d5dc9d0c7f4d4c151 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:35:35 +0300 Subject: [PATCH 15/19] Changelog update - `v2.22.3` (#580) * Changelog update - v2.22.3 * chore: trigger CI build --------- Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa13a55..177650e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.22.3 - 2025-09-19 + ### Fixed - relaxed SNI hostname resolution From 546d317b2219a8674a9c3a7cc10fc4f6dd3ca0f6 Mon Sep 17 00:00:00 2001 From: Jiachen Jiang Date: Mon, 29 Sep 2025 12:40:58 -0700 Subject: [PATCH 16/19] Update README.md (#583) * Update README.md added note to recommend the Toolbox plugin * Update README.md Added link to docs --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index fd67a38d..48d5f8a3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Follow](https://img.shields.io/twitter/follow/CoderHQ?label=%40CoderHQ&style=soc The Coder Gateway plugin lets you open [Coder](https://github.com/coder/coder) workspaces in your JetBrains IDEs with a single click. +> [!NOTE] +> We recommend using the [Coder Toolbox plugin](https://github.com/coder/coder-jetbrains-toolbox), which offers significant stability and connectivity benefits over Gateway. Future updates of the Coder plugin on Jetbrains will be made to the Toolbox plugin. Reference our [documentation](https://coder.com/docs/user-guides/workspace-access/jetbrains/toolbox) for more information. + + **Manage less** - Ensure your entire team is using the same tools and resources From 9bff36706e64a5cff78382fe0653132bf8d62e23 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 2 Oct 2025 01:00:56 +0300 Subject: [PATCH 17/19] impl: prefer agent name over agent id in URI handler (#585) * impl: prefer agent name over agent id in URI handler Agent name is easier to resolve in jetbrains gateway module * impl: prefer agent name over agent id in URI handler (2) Error reporting should also prefer agent name over the agent id. * chore: update and fix UTs Test cases related to agent matching have to be updated now that agent name is preferred over agent id. * chore: refactor UTs related to agent matching in URI handling Rewrote the entire test to be easier to read and debug. --- .../com/coder/gateway/util/LinkHandler.kt | 29 +- .../kotlin/com/coder/gateway/util/LinkMap.kt | 1 + .../com/coder/gateway/util/LinkHandlerTest.kt | 489 ++++++++++++------ 3 files changed, 358 insertions(+), 161 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index 6ac93efa..aec1cd47 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt @@ -327,25 +327,24 @@ internal fun getMatchingAgent( } // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (!parameters.agentName().isNullOrBlank()) { - agents.firstOrNull { it.name == parameters.agentName() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } + // Prefer the name over the id if both are set. + val agent = if (!parameters.agentName().isNullOrBlank()) { + agents.firstOrNull { it.name == parameters.agentName() } + } else if (!parameters.agentID().isNullOrBlank()) { + agents.firstOrNull { it.id.toString() == parameters.agentID() } + } else if (agents.size == 1) { + agents.first() + } else { + null + } if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else if (!parameters.agentName().isNullOrBlank()) { + if (!parameters.agentName().isNullOrBlank()) { throw IllegalArgumentException( - "The workspace \"${workspace.name}\"does not have an agent named \"${parameters.agentName()}\"", + "The workspace \"${workspace.name}\" does not have an agent named \"${parameters.agentName()}\"", ) + } else if (!parameters.agentID().isNullOrBlank()) { + throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") } else { throw MissingArgumentException( "Unable to determine which agent to connect to; one of \"$AGENT_NAME\" or \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", diff --git a/src/main/kotlin/com/coder/gateway/util/LinkMap.kt b/src/main/kotlin/com/coder/gateway/util/LinkMap.kt index 4c93d221..c79be91e 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkMap.kt @@ -29,6 +29,7 @@ fun Map.owner() = this[OWNER] fun Map.agentName() = this[AGENT_NAME] +@Deprecated("Use the agent name instead") fun Map.agentID() = this[AGENT_ID] fun Map.folder() = this[FOLDER] diff --git a/src/test/kotlin/com/coder/gateway/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/gateway/util/LinkHandlerTest.kt index 8925fc44..662dc976 100644 --- a/src/test/kotlin/com/coder/gateway/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/gateway/util/LinkHandlerTest.kt @@ -12,199 +12,396 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith internal class LinkHandlerTest { - /** - * Create, start, and return a server that uses the provided handler. - */ - private fun mockServer(handler: HttpHandler): Pair { - val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/", handler) - srv.start() - return Pair(srv, "http://localhost:" + srv.address.port) - } - /** - * Create, start, and return a server that mocks redirects. - */ - private fun mockRedirectServer( - location: String, - temp: Boolean, - ): Pair = mockServer { exchange -> - exchange.responseHeaders.set("Location", location) - exchange.sendResponseHeaders( - if (temp) HttpURLConnection.HTTP_MOVED_TEMP else HttpURLConnection.HTTP_MOVED_PERM, - -1, - ) - exchange.close() - } + // Test data setup + private companion object { + val AGENT_1 = AgentTestData(name = "agent_name", id = "9a920eee-47fb-4571-9501-e4b3120c12f2") + val AGENT_2 = AgentTestData(name = "agent_name_2", id = "fb3daea4-da6b-424d-84c7-36b90574cfef") + val AGENT_3 = AgentTestData(name = "agent_name_3", id = "b0e4c54d-9ba9-4413-8512-11ca1e826a24") - private val agents = - mapOf( - "agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - "agent_name_2" to "fb3daea4-da6b-424d-84c7-36b90574cfef", - "agent_name" to "9a920eee-47fb-4571-9501-e4b3120c12f2", - ) - private val oneAgent = - mapOf( - "agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", + val ALL_AGENTS = mapOf( + AGENT_3.name to AGENT_3.id, + AGENT_2.name to AGENT_2.id, + AGENT_1.name to AGENT_1.id ) + val SINGLE_AGENT = mapOf(AGENT_3.name to AGENT_3.id) + } + @Test - fun getMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - - val tests = - listOf( - Pair(mapOf("agent" to "agent_name"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), - Pair(mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), - Pair(mapOf("agent" to "agent_name_2"), "fb3daea4-da6b-424d-84c7-36b90574cfef"), - Pair(mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"), "fb3daea4-da6b-424d-84c7-36b90574cfef"), - Pair(mapOf("agent" to "agent_name_3"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - Pair(mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - // Prefer agent_id. - Pair( - mapOf( - "agent" to "agent_name", - "agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ), - "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ), + fun `getMatchingAgent finds agent by name`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentMatchTestCase( + "matches agent_name", + mapOf("agent" to AGENT_1.name), + AGENT_1.id + ), + AgentMatchTestCase( + "matches agent_name_2", + mapOf("agent" to AGENT_2.name), + AGENT_2.id + ), + AgentMatchTestCase( + "matches agent_name_3", + mapOf("agent" to AGENT_3.name), + AGENT_3.id ) + ) - tests.forEach { - assertEquals(UUID.fromString(it.second), getMatchingAgent(it.first, ws).id) + testCases.forEach { testCase -> + assertEquals( + UUID.fromString(testCase.expectedAgentId), + getMatchingAgent(testCase.params, ws).id, + "Failed: ${testCase.description}" + ) } } @Test - fun failsToGetMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - val tests = - listOf( - Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "ws.agent_name"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "agent_name_4"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent_id" to "not-a-uuid"), IllegalArgumentException::class, "agent with ID"), - Triple(mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), IllegalArgumentException::class, "agent with ID"), - // Will ignore agent if agent_id is set even if agent matches. - Triple( - mapOf( - "agent" to "agent_name", - "agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168", - ), - IllegalArgumentException::class, - "agent with ID", - ), + fun `getMatchingAgent finds agent by ID`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentMatchTestCase( + "matches by agent_1 ID", + mapOf("agent_id" to AGENT_1.id), + AGENT_1.id + ), + AgentMatchTestCase( + "matches by agent_2 ID", + mapOf("agent_id" to AGENT_2.id), + AGENT_2.id + ), + AgentMatchTestCase( + "matches by agent_3 ID", + mapOf("agent_id" to AGENT_3.id), + AGENT_3.id ) + ) - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + testCases.forEach { testCase -> + assertEquals( + UUID.fromString(testCase.expectedAgentId), + getMatchingAgent(testCase.params, ws).id, + "Failed: ${testCase.description}" + ) } } @Test - fun getsFirstAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = oneAgent) - val tests = - listOf( + fun `getMatchingAgent prefers agent name over agent_id`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val params = mapOf( + "agent" to AGENT_3.name, + "agent_id" to AGENT_2.id + ) + + assertEquals(AGENT_3.uuid, getMatchingAgent(params, ws).id) + } + + @Test + fun `getMatchingAgent fails with missing parameters`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentFailureTestCase( + "empty parameters (i.e. no agent name or id provided)", emptyMap(), + MissingArgumentException::class, + "Unable to determine which agent to connect to; one of \"agent\" or \"agent_id\" must be set because the workspace \"ws\" has more than one agent" + ), + AgentFailureTestCase( + "empty agent name", mapOf("agent" to ""), + MissingArgumentException::class, + "Unable to determine which agent to connect to; one of \"agent\" or \"agent_id\" must be set because the workspace \"ws\" has more than one agent" + ), + AgentFailureTestCase( + "empty agent id", mapOf("agent_id" to ""), + MissingArgumentException::class, + "Unable to determine which agent to connect to; one of \"agent\" or \"agent_id\" must be set because the workspace \"ws\" has more than one agent" + ), + AgentFailureTestCase( + "null agent name", mapOf("agent" to null), + MissingArgumentException::class, + "Unable to determine which agent to connect to; one of \"agent\" or \"agent_id\" must be set because the workspace \"ws\" has more than one agent" + ), + AgentFailureTestCase( + "null agent id", mapOf("agent_id" to null), + MissingArgumentException::class, + "Unable to determine which agent to connect to; one of \"agent\" or \"agent_id\" must be set because the workspace \"ws\" has more than one agent" ) + ) + + testCases.forEach { testCase -> + val ex = assertFailsWith( + exceptionClass = testCase.expectedException, + message = "Failed: ${testCase.description}" + ) { + getMatchingAgent(testCase.params, ws).id + } + assertContains(ex.message.toString(), testCase.expectedMessageFragment) + } + } - tests.forEach { + @Test + fun `getMatchingAgent fails with invalid agent references`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentFailureTestCase( + "workspace name instead of agent", + mapOf("agent" to "ws"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"ws\"" + ), + AgentFailureTestCase( + "qualified agent name", + mapOf("agent" to "ws.agent_name"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"ws.agent_name\"" + ), + AgentFailureTestCase( + "non-existent agent name", + mapOf("agent" to "agent_name_4"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"agent_name_4\"" + ), + AgentFailureTestCase( + "invalid UUID format", + mapOf("agent_id" to "not-a-uuid"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent with ID \"not-a-uuid\"" + ), + AgentFailureTestCase( + "non-existent agent ID", + mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent with ID \"ceaa7bcf-1612-45d7-b484-2e0da9349168\"" + ), + AgentFailureTestCase( + "ignores valid agent_id when agent name is invalid", + mapOf( + "agent" to "unknown_agent_name", + "agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24" + ), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"unknown_agent_name\"" + ) + ) + + testCases.forEach { testCase -> + val ex = assertFailsWith( + exceptionClass = testCase.expectedException, + message = "Failed: ${testCase.description}" + ) { + getMatchingAgent(testCase.params, ws).id + } + assertContains(ex.message.toString(), testCase.expectedMessageFragment) + } + } + + @Test + fun `getMatchingAgent returns only agent when workspace has one agent`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCases = listOf( + "empty parameters (i.e. no agent name or id provided)" to emptyMap(), + "empty agent name" to mapOf("agent" to ""), + "empty agent id" to mapOf("agent_id" to ""), + "null agent name" to mapOf("agent" to null), + "null agent id" to mapOf("agent_id" to null) + ) + + testCases.forEach { (description, params) -> assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - getMatchingAgent( - it, - ws, - ).id, + AGENT_3.uuid, + getMatchingAgent(params, ws).id, + "Failed: $description" ) } } @Test - fun failsToGetAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = oneAgent) - val tests = - listOf( - Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "ws.agent_name_3"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "agent_name_4"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), IllegalArgumentException::class, "agent with ID"), + fun `getMatchingAgent fails with invalid references in single-agent workspace`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCases = listOf( + AgentFailureTestCase( + "invalid agent name provided, i.e. the workspace name", + mapOf("agent" to "ws"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"ws\"" + ), + AgentFailureTestCase( + "qualified agent name", + mapOf("agent" to "ws.agent_name_3"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"ws.agent_name_3\"" + ), + AgentFailureTestCase( + "non-existent agent", + mapOf("agent" to "agent_name_4"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent named \"agent_name_4\"" + ), + AgentFailureTestCase( + "non-existent agent ID", + mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), + IllegalArgumentException::class, + "The workspace \"ws\" does not have an agent with ID \"ceaa7bcf-1612-45d7-b484-2e0da9349168\"" ) + ) - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + testCases.forEach { testCase -> + val ex = assertFailsWith( + exceptionClass = testCase.expectedException, + message = "Failed: ${testCase.description}" + ) { + getMatchingAgent(testCase.params, ws).id + } + assertContains(ex.message.toString(), testCase.expectedMessageFragment) } } @Test - fun failsToGetAgentWithoutAgents() { + fun `getMatchingAgent fails when workspace has no agents`() { val ws = DataGen.workspace("ws") - val tests = - listOf( - Triple(emptyMap(), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to null), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to null), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to "agent_name"), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), IllegalArgumentException::class, "has no agents"), + + val testCases = listOf( + AgentFailureTestCase( + "empty map", + emptyMap(), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" + ), + AgentFailureTestCase( + "empty agent string", + mapOf("agent" to ""), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" + ), + AgentFailureTestCase( + "empty agent_id string", + mapOf("agent_id" to ""), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" + ), + AgentFailureTestCase( + "null agent", + mapOf("agent" to null), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" + ), + AgentFailureTestCase( + "null agent_id", + mapOf("agent_id" to null), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" + ), + AgentFailureTestCase( + "valid agent name", + mapOf("agent" to "agent_name"), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" + ), + AgentFailureTestCase( + "valid agent ID", + mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), + IllegalArgumentException::class, + "The workspace \"ws\" has no agents" ) + ) - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + testCases.forEach { testCase -> + val ex = assertFailsWith( + exceptionClass = testCase.expectedException, + message = "Failed: ${testCase.description}" + ) { + getMatchingAgent(testCase.params, ws).id + } + assertContains(ex.message.toString(), testCase.expectedMessageFragment) } } @Test - fun followsRedirects() { - val (srv1, url1) = - mockServer { exchange -> - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) - exchange.close() - } - val (srv2, url2) = mockRedirectServer(url1, false) - val (srv3, url3) = mockRedirectServer(url2, true) + fun `resolveRedirects follows redirect chain`() { + val finalServer = mockOkServer() + val permanentRedirect = mockRedirectServer(finalServer.url, isPermanent = true) + val temporaryRedirect = mockRedirectServer(permanentRedirect.url, isPermanent = false) - assertEquals(url1.toURL(), resolveRedirects(java.net.URL(url3))) - - srv1.stop(0) - srv2.stop(0) - srv3.stop(0) + try { + assertEquals( + finalServer.url.toURL(), + resolveRedirects(temporaryRedirect.url.toURL()) + ) + } finally { + finalServer.stop() + permanentRedirect.stop() + temporaryRedirect.stop() + } } @Test - fun followsMaximumRedirects() { - val (srv, url) = mockRedirectServer(".", true) + fun `resolveRedirects fails on infinite redirect loop`() { + val server = mockRedirectServer(".", isPermanent = false) - assertFailsWith( - exceptionClass = Exception::class, - block = { resolveRedirects(java.net.URL(url)) }, - ) + try { + assertFailsWith { + resolveRedirects(server.url.toURL()) + } + } finally { + server.stop() + } + } + + internal data class AgentTestData(val name: String, val id: String) { + val uuid: UUID get() = UUID.fromString(id) + } + + internal data class AgentMatchTestCase( + val description: String, + val params: Map, + val expectedAgentId: String + ) - srv.stop(0) + internal data class AgentFailureTestCase( + val description: String, + val params: Map, + val expectedException: kotlin.reflect.KClass, + val expectedMessageFragment: String + ) + + // Helper classes for cleaner test server management + private data class TestServer(val httpServer: HttpServer, val url: String) { + fun stop() = httpServer.stop(0) + } + + private fun mockServer(handler: HttpHandler): TestServer { + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/", handler) + server.start() + return TestServer(server, "http://localhost:${server.address.port}") } -} + + private fun mockOkServer(): TestServer = mockServer { exchange -> + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) + exchange.close() + } + + private fun mockRedirectServer(location: String, isPermanent: Boolean): TestServer = + mockServer { exchange -> + exchange.responseHeaders.set("Location", location) + exchange.sendResponseHeaders( + if (isPermanent) HttpURLConnection.HTTP_MOVED_PERM else HttpURLConnection.HTTP_MOVED_TEMP, + -1 + ) + exchange.close() + } +} \ No newline at end of file From aab59160393c4e0f8806700557e51914ba3ccea4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 2 Oct 2025 01:16:36 +0300 Subject: [PATCH 18/19] impl: add the option to disable ssh wildcard configuration (#584) * impl: add the option to disable ssh wildcard configuration It will be used later by the Coder Settings view to allow users to enable or disable SSH hostname wildcard configuration. * impl: expose ssh wildcard config in the Settings page Updated the UI component to allow configuration by the user * impl: take into account wildcard configuration when generating the ssh config for Coder Gateway. Up until now we just checked if the Coder deployment supports this feature, but now users have to option to continue to use expanded hostnames in the ssh config. * fix: force CLI manager to use user settings CLIManager can be created with default settings (simplifies testing), among which the ssh wildcard config is enabled. But in reality the config can be disabled by the user. * impl: ability to start a recent workspace connection after ssh wildcard config was changed Currently, if a user starts a connection with wildcard enabled and then later on it disables the wildcard config then the recent connections becomes unusable because the hostnames are invalid. The issue can reproduce the other way around as well (start with wildcard ssh config disabled, start an IDE and then later on enable wildcard config) This commit addresses the issue by resolving the hostname on demand when the user wants to open the remote IDE from the recent connections panel. * chore: update README * chore: next version is 2.23.0 * fix: don't show twice the connection to the same workspace Connections started with two different hostnames (because of the ssh wildcard config) can be rendered twice in the Recent projects panel. With this commit we ignore the hostname and instead use the workspace name and deployment URL. --- CHANGELOG.md | 4 +++ gradle.properties | 2 +- .../gateway/CoderSettingsConfigurable.kt | 25 +++++++++++++------ .../com/coder/gateway/cli/CoderCLIManager.kt | 9 +++---- .../models/RecentWorkspaceConnection.kt | 17 ++++++++----- .../gateway/models/WorkspaceProjectIDE.kt | 2 +- .../coder/gateway/settings/CoderSettings.kt | 12 +++++++++ .../com/coder/gateway/util/LinkHandler.kt | 2 +- .../kotlin/com/coder/gateway/util/Without.kt | 16 ++++++++++++ ...erGatewayRecentWorkspaceConnectionsView.kt | 24 +++++++++++++++--- .../steps/CoderWorkspaceProjectIDEStepView.kt | 10 +++++--- .../messages/CoderGatewayBundle.properties | 4 ++- .../coder/gateway/cli/CoderCLIManagerTest.kt | 2 +- 13 files changed, 99 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 177650e2..dbea5ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Added + +- support for disabling SSH wildcard config. + ## 2.22.3 - 2025-09-19 ### Fixed diff --git a/gradle.properties b/gradle.properties index edaf8108..26ae7b23 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.22.3 +pluginVersion=2.23.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 64a140b4..76096e98 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -120,13 +120,24 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.comment"), ) }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.heading")) { - checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.title")) - .bindSelected(state::disableAutostart) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.comment"), - ) - }.layout(RowLayout.PARENT_GRID) + group { + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.title")) + .bindSelected(state::disableAutostart) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.wildcard-config.title")) + .bindSelected(state::isSshWildcardConfigEnabled) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.wildcard-config.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + } row(CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.title")) { textArea().resizableColumn().align(AlignX.FILL) .bindText(state::sshConfigOptions) diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index cfa7deef..74c3ee88 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -12,7 +12,6 @@ import com.coder.gateway.sdk.v2.models.User import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.settings.CoderSettings -import com.coder.gateway.settings.CoderSettingsState import com.coder.gateway.util.CoderHostnameVerifier import com.coder.gateway.util.DialogUi import com.coder.gateway.util.InvalidVersionException @@ -129,7 +128,7 @@ class CoderCLIManager( // The URL of the deployment this CLI is for. private val deploymentURL: URL, // Plugin configuration. - private val settings: CoderSettings = CoderSettings(CoderSettingsState()), + private val settings: CoderSettings, // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. private val forceDownloadToData: Boolean = false, @@ -373,7 +372,7 @@ class CoderCLIManager( SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent() val blockContent = - if (feats.wildcardSSH) { + if (settings.isSshWildcardConfigEnabled && feats.wildcardSSH) { startBlock + System.lineSeparator() + """ Host ${getHostPrefix()}--* @@ -622,7 +621,7 @@ class CoderCLIManager( workspace: Workspace, currentUser: User, agent: WorkspaceAgent, - ): String = if (features.wildcardSSH) { + ): String = if (settings.isSshWildcardConfigEnabled && features.wildcardSSH) { "${getHostPrefix()}--${workspace.ownerName}--${workspace.name}.${agent.name}" } else { // For a user's own workspace, we use the old syntax without a username for backwards compatibility, @@ -638,7 +637,7 @@ class CoderCLIManager( workspace: Workspace, currentUser: User, agent: WorkspaceAgent, - ): String = if (features.wildcardSSH) { + ): String = if (settings.isSshWildcardConfigEnabled && features.wildcardSSH) { "${getHostPrefix()}-bg--${workspace.ownerName}--${workspace.name}.${agent.name}" } else { getHostName(workspace, currentUser, agent) + "--bg" diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index 17e03977..ba2eb719 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -82,7 +82,8 @@ class RecentWorkspaceConnection( other as RecentWorkspaceConnection - if (coderWorkspaceHostname != other.coderWorkspaceHostname) return false + if (name != other.name) return false + if (deploymentURL != other.deploymentURL) return false if (projectPath != other.projectPath) return false if (ideProductCode != other.ideProductCode) return false if (ideBuildNumber != other.ideBuildNumber) return false @@ -92,7 +93,8 @@ class RecentWorkspaceConnection( override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (coderWorkspaceHostname?.hashCode() ?: 0) + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + (deploymentURL?.hashCode() ?: 0) result = 31 * result + (projectPath?.hashCode() ?: 0) result = 31 * result + (ideProductCode?.hashCode() ?: 0) result = 31 * result + (ideBuildNumber?.hashCode() ?: 0) @@ -101,18 +103,21 @@ class RecentWorkspaceConnection( } override fun compareTo(other: RecentWorkspaceConnection): Int { - val i = other.coderWorkspaceHostname?.let { coderWorkspaceHostname?.compareTo(it) } + val i = other.name?.let { name?.compareTo(it) } if (i != null && i != 0) return i - val j = other.projectPath?.let { projectPath?.compareTo(it) } + val j = other.deploymentURL?.let { deploymentURL?.compareTo(it) } if (j != null && j != 0) return j - val k = other.ideProductCode?.let { ideProductCode?.compareTo(it) } + val k = other.projectPath?.let { projectPath?.compareTo(it) } if (k != null && k != 0) return k - val l = other.ideBuildNumber?.let { ideBuildNumber?.compareTo(it) } + val l = other.ideProductCode?.let { ideProductCode?.compareTo(it) } if (l != null && l != 0) return l + val m = other.ideBuildNumber?.let { ideBuildNumber?.compareTo(it) } + if (m != null && m != 0) return m + return 0 } } diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt index 287f1bd4..d1d33b08 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt @@ -18,7 +18,7 @@ private val NON_STABLE_RELEASE_TYPES = setOf("EAP", "RC", "NIGHTLY", "PREVIEW") * Validated parameters for downloading and opening a project using an IDE on a * workspace. */ -class WorkspaceProjectIDE( +data class WorkspaceProjectIDE( // Either `workspace.agent` for old connections or `user/workspace.agent` // for new connections. val name: String, diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index aa517746..a189df82 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -98,6 +98,12 @@ open class CoderSettingsState( // around issues on macOS where it periodically wakes and Gateway // reconnects, keeping the workspace constantly up. open var disableAutostart: Boolean = getOS() == OS.MAC, + + /** + * Whether SSH wildcard config is enabled + */ + open var isSshWildcardConfigEnabled: Boolean = true, + // Extra SSH config options. open var sshConfigOptions: String = "", // An external command to run in the directory of the IDE before connecting @@ -199,6 +205,12 @@ open class CoderSettings( val disableAutostart: Boolean get() = state.disableAutostart + /** + * Whether SSH wildcard config is enabled + */ + val isSshWildcardConfigEnabled: Boolean + get() = state.isSshWildcardConfigEnabled + /** * Extra SSH config to append to each host block. */ diff --git a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index aec1cd47..cb943552 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt @@ -74,7 +74,7 @@ open class LinkHandler( var workspace: Workspace var workspaces: List = emptyList() var workspacesAndAgents: Set> = emptySet() - if (cli.features.wildcardSSH) { + if (settings.isSshWildcardConfigEnabled && cli.features.wildcardSSH) { workspace = client.workspaceByOwnerAndName(owner, workspaceName) } else { workspaces = client.workspaces() diff --git a/src/main/kotlin/com/coder/gateway/util/Without.kt b/src/main/kotlin/com/coder/gateway/util/Without.kt index 8ba79ae0..946706a7 100644 --- a/src/main/kotlin/com/coder/gateway/util/Without.kt +++ b/src/main/kotlin/com/coder/gateway/util/Without.kt @@ -14,6 +14,22 @@ fun withoutNull( return block(a) } +/** + * Run block with provided arguments after checking they are all non-null. This + * is to enforce non-null values and should be used to signify developer error. + */ +fun withoutNull( + a: A?, + b: B?, + c: C?, + block: (a: A, b: B, c: C) -> Z, +): Z { + if (a == null || b == null || c == null) { + throw Exception("Unexpected null value") + } + return block(a, b, c) +} + /** * Run block with provided arguments after checking they are all non-null. This * is to enforce non-null values and should be used to signify developer error. diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index c679c451..1ec9596a 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -5,6 +5,7 @@ package com.coder.gateway.views import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.CoderGatewayConstants import com.coder.gateway.CoderRemoteConnectionHandle +import com.coder.gateway.cli.CoderCLIManager import com.coder.gateway.cli.ensureCLI import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.WorkspaceAgentListModel @@ -177,6 +178,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: it.workspace.ownerName + "/" + it.workspace.name == workspaceName || (it.workspace.ownerName == me && it.workspace.name == workspaceName) } + val status = if (deploymentError != null) { Triple(UIUtil.getErrorForeground(), deploymentError, UIUtil.getBalloonErrorIcon()) @@ -250,10 +252,11 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: ActionLink(workspaceProjectIDE.projectPathDisplay) { withoutNull( deployment?.client, - workspaceWithAgent?.workspace - ) { client, workspace -> + workspaceWithAgent?.workspace, + workspaceWithAgent?.agent + ) { client, workspace, agent -> CoderRemoteConnectionHandle().connect { - if (listOf( + val cli = if (listOf( WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED @@ -272,8 +275,21 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: } cli.startWorkspace(workspace.ownerName, workspace.name) + cli + } else { + CoderCLIManager(deploymentURL.toURL(), settings) } - workspaceProjectIDE + // the ssh config could have changed in the meantime + // so we want to make sure we use a proper hostname + // depending on whether the ssh wildcard config + // is enabled, otherwise the connection will fail. + workspaceProjectIDE.copy( + hostname = cli.getHostName( + workspace, + client.me(), + agent + ) + ) } GatewayUI.getInstance().reset() } diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt index 81f02b2a..e5d4616f 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt @@ -214,7 +214,7 @@ class CoderWorkspaceProjectIDEStepView( logger.info("Configuring Coder CLI...") cbIDE.renderer = IDECellRenderer("Configuring Coder CLI...") withContext(Dispatchers.IO) { - if (data.cliManager.features.wildcardSSH) { + if (settings.isSshWildcardConfigEnabled && data.cliManager.features.wildcardSSH) { data.cliManager.configSsh(emptySet(), data.client.me) } else { data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me) @@ -237,7 +237,7 @@ class CoderWorkspaceProjectIDEStepView( IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh")) } val executor = createRemoteExecutor( - CoderCLIManager(data.client.url).getBackgroundHostName( + CoderCLIManager(data.client.url, settings).getBackgroundHostName( data.workspace, data.client.me, data.agent @@ -470,7 +470,11 @@ class CoderWorkspaceProjectIDEStepView( override fun data(): WorkspaceProjectIDE = withoutNull(cbIDE.selectedItem, state) { selectedIDE, state -> selectedIDE.withWorkspaceProject( name = CoderCLIManager.getWorkspaceParts(state.workspace, state.agent), - hostname = CoderCLIManager(state.client.url).getHostName(state.workspace, state.client.me, state.agent), + hostname = CoderCLIManager(state.client.url, settings).getHostName( + state.workspace, + state.client.me, + state.agent + ), projectPath = tfProject.text, deploymentURL = state.client.url, ) diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 7420b576..00afdbfa 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -104,12 +104,14 @@ gateway.connector.settings.tls-alt-name.comment=Optionally set this to \ an alternate hostname used for verifying TLS connections. This is useful \ when the hostname used to connect to the Coder service does not match the \ hostname in the TLS certificate. -gateway.connector.settings.disable-autostart.heading=Autostart gateway.connector.settings.disable-autostart.title=Disable autostart gateway.connector.settings.disable-autostart.comment=Checking this box will \ cause the plugin to configure the CLI with --disable-autostart. You must go \ through the IDE selection again for the plugin to reconfigure the CLI with \ this setting. +gateway.connector.settings.wildcard-config.title=Enable SSH wildcard config +gateway.connector.settings.wildcard-config.comment=Enables or disables wildcard \ + entries in the SSH configuration, which allows generic rules for matching multiple workspaces gateway.connector.settings.ssh-config-options.title=SSH config options gateway.connector.settings.ssh-config-options.comment=Extra SSH config options \ to use when connecting to a workspace. This text will be appended as-is to \ diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index d83690b7..3869f9e7 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -106,7 +106,7 @@ internal class CoderCLIManagerTest { @Test fun testServerInternalError() { val (srv, url) = mockServer(HttpURLConnection.HTTP_INTERNAL_ERROR) - val ccm = CoderCLIManager(url) + val ccm = CoderCLIManager(url, CoderSettings(CoderSettingsState())) val ex = assertFailsWith( From 51d1d57bc80e8173b6603a8c4c39b8ffc0e6cbde Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:08:40 +0300 Subject: [PATCH 19/19] Changelog update - `v2.23.0` (#586) * Changelog update - v2.23.0 * chore: for build trigger --------- Co-authored-by: GitHub Action Co-authored-by: Faur Ioan-Aurel --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbea5ef8..f0c2a25b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.23.0 - 2025-10-02 + ### Added - support for disabling SSH wildcard config.