From 870b8d3067009cc22541c6f138d054b32f623ad4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 00:08:06 +0300 Subject: [PATCH 1/8] 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. --- .../com/coder/gateway/settings/CoderSettings.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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. */ From a6de12a4cb0089298ad24f602b9cc11dcdb7279a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 00:09:41 +0300 Subject: [PATCH 2/8] impl: expose ssh wildcard config in the Settings page Updated the UI component to allow configuration by the user --- .../gateway/CoderSettingsConfigurable.kt | 25 +++++++++++++------ .../messages/CoderGatewayBundle.properties | 4 ++- 2 files changed, 21 insertions(+), 8 deletions(-) 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/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 \ From 4f6482c63896d02d6f9a9f26d2edaf5ce5a7e661 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 00:12:08 +0300 Subject: [PATCH 3/8] 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. --- src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt | 6 +++--- src/main/kotlin/com/coder/gateway/util/LinkHandler.kt | 2 +- .../gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index cfa7deef..c1eac0cb 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -373,7 +373,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 +622,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 +638,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/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index 6ac93efa..3322381b 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/views/steps/CoderWorkspaceProjectIDEStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt index 81f02b2a..821ad313 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) From 49336ca804eec0815efb8307f94235aa81178c59 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 22:20:28 +0300 Subject: [PATCH 4/8] 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. --- src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt | 3 +-- .../views/steps/CoderWorkspaceProjectIDEStepView.kt | 8 ++++++-- .../kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index c1eac0cb..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, 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 821ad313..e5d4616f 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt @@ -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/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 dac5c717e3899b13c7b314dbf1948b5d5288d047 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 23:26:47 +0300 Subject: [PATCH 5/8] 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. --- .../gateway/models/WorkspaceProjectIDE.kt | 2 +- .../kotlin/com/coder/gateway/util/Without.kt | 16 +++++++++++++ ...erGatewayRecentWorkspaceConnectionsView.kt | 24 +++++++++++++++---- 3 files changed, 37 insertions(+), 5 deletions(-) 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/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() } From b6f5dcc60cebe32bd3e3f53dd7074cadfd7a9808 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 23:27:46 +0300 Subject: [PATCH 6/8] chore: update README --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From acc3eba0c9788d7bee33e7f2c451d70ee1a46fe6 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 23:28:46 +0300 Subject: [PATCH 7/8] chore: next version is 2.23.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From da4f2ecc24de0332e825bc0b15972a3a2dc5b291 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 30 Sep 2025 23:50:51 +0300 Subject: [PATCH 8/8] 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. --- .../gateway/models/RecentWorkspaceConnection.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 } }