diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 1e5f2354..e6e7a13a 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -2,6 +2,7 @@ package com.coder.gateway import com.coder.gateway.services.CoderSettingsService import com.coder.gateway.services.CoderSettingsStateService +import com.coder.gateway.settings.CODER_SSH_CONFIG_OPTIONS import com.coder.gateway.util.canCreateDirectory import com.intellij.openapi.components.service import com.intellij.openapi.options.BoundConfigurable @@ -109,6 +110,13 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.comment") ) }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.title")) { + textArea().resizableColumn().align(AlignX.FILL) + .bindText(state::sshConfigOptions) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.comment", CODER_SSH_CONFIG_OPTIONS) + ) + }.layout(RowLayout.PARENT_GRID) } } diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index 1a491e64..a6190d94 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -255,6 +255,9 @@ class CoderCLIManager( if (settings.headerCommand.isNotBlank()) escapeSubcommand(settings.headerCommand) else null, "ssh", "--stdio", if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null) + val extraConfig = if (settings.sshConfigOptions.isNotBlank()) { + "\n" + settings.sshConfigOptions.prependIndent(" ") + } else "" val blockContent = workspaceNames.joinToString( System.lineSeparator(), startBlock + System.lineSeparator(), @@ -268,7 +271,9 @@ class CoderCLIManager( UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains - """.trimIndent().replace("\n", System.lineSeparator()) + """.trimIndent() + .plus(extraConfig) + .replace("\n", System.lineSeparator()) }) if (contents == null) { diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index 66d17829..949afe7a 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -14,6 +14,8 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +const val CODER_SSH_CONFIG_OPTIONS = "CODER_SSH_CONFIG_OPTIONS"; + open class CoderSettingsState( // Used to download the Coder CLI which is necessary to proxy SSH // connections. The If-None-Match header will be set to the SHA1 of the CLI @@ -57,6 +59,8 @@ 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, + // Extra SSH config options. + open var sshConfigOptions: String = "", ) /** @@ -113,6 +117,12 @@ open class CoderSettings( val disableAutostart: Boolean get() = state.disableAutostart + /** + * Extra SSH config to append to each host block. + */ + val sshConfigOptions: String + get() = state.sshConfigOptions.ifBlank { env.get(CODER_SSH_CONFIG_OPTIONS) } + /** * Where the specified deployment should put its data. */ diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 4b1a334f..0361e13f 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -117,3 +117,9 @@ 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.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 \ + the SSH configuration block for each workspace. If left blank the \ + environment variable {0} will be used, if set. + diff --git a/src/test/fixtures/outputs/extra-config.conf b/src/test/fixtures/outputs/extra-config.conf new file mode 100644 index 00000000..5dcff2c8 --- /dev/null +++ b/src/test/fixtures/outputs/extra-config.conf @@ -0,0 +1,11 @@ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--extra--test.coder.invalid + ProxyCommand /tmp/coder-gateway/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-gateway/test.coder.invalid/config ssh --stdio extra + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + ServerAliveInterval 5 + ServerAliveCountMax 3 +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index f064578f..a3a7dd26 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -3,8 +3,10 @@ package com.coder.gateway.cli 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.settings.CoderSettingsState +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.InvalidVersionException import com.coder.gateway.util.OS import com.coder.gateway.util.SemVer @@ -238,34 +240,43 @@ internal class CoderCLIManagerTest { val input: String?, val output: String, val remove: String, - val headerCommand: String?, + val headerCommand: String = "", val disableAutostart: Boolean = false, - val features: Features? = null, + val features: Features = Features(), + val extraConfig: String = "", + val env: Environment = Environment(), ) @Test fun testConfigureSSH() { + val extraConfig = listOf( + "ServerAliveInterval 5", + "ServerAliveCountMax 3").joinToString(System.lineSeparator()) val tests = listOf( - SSHTest(listOf("foo", "bar"), null,"multiple-workspaces", "blank", null), - SSHTest(listOf("foo", "bar"), null,"multiple-workspaces", "blank", null), - SSHTest(listOf("foo-bar"), "blank", "append-blank", "blank", null), - SSHTest(listOf("foo-bar"), "blank-newlines", "append-blank-newlines", "blank", null), - SSHTest(listOf("foo-bar"), "existing-end", "replace-end", "no-blocks", null), - SSHTest(listOf("foo-bar"), "existing-end-no-newline", "replace-end-no-newline", "no-blocks", null), - SSHTest(listOf("foo-bar"), "existing-middle", "replace-middle", "no-blocks", null), - SSHTest(listOf("foo-bar"), "existing-middle-and-unrelated", "replace-middle-ignore-unrelated", "no-related-blocks", null), - SSHTest(listOf("foo-bar"), "existing-only", "replace-only", "blank", null), - SSHTest(listOf("foo-bar"), "existing-start", "replace-start", "no-blocks", null), - SSHTest(listOf("foo-bar"), "no-blocks", "append-no-blocks", "no-blocks", null), - SSHTest(listOf("foo-bar"), "no-related-blocks", "append-no-related-blocks", "no-related-blocks", null), - SSHTest(listOf("foo-bar"), "no-newline", "append-no-newline", "no-blocks", null), + SSHTest(listOf("foo", "bar"), null, "multiple-workspaces", "blank"), + SSHTest(listOf("foo", "bar"), null, "multiple-workspaces", "blank"), + SSHTest(listOf("foo-bar"), "blank", "append-blank", "blank"), + SSHTest(listOf("foo-bar"), "blank-newlines", "append-blank-newlines", "blank"), + SSHTest(listOf("foo-bar"), "existing-end", "replace-end", "no-blocks"), + SSHTest(listOf("foo-bar"), "existing-end-no-newline", "replace-end-no-newline", "no-blocks"), + SSHTest(listOf("foo-bar"), "existing-middle", "replace-middle", "no-blocks"), + SSHTest(listOf("foo-bar"), "existing-middle-and-unrelated", "replace-middle-ignore-unrelated", "no-related-blocks"), + SSHTest(listOf("foo-bar"), "existing-only", "replace-only", "blank"), + SSHTest(listOf("foo-bar"), "existing-start", "replace-start", "no-blocks"), + SSHTest(listOf("foo-bar"), "no-blocks", "append-no-blocks", "no-blocks"), + SSHTest(listOf("foo-bar"), "no-related-blocks", "append-no-related-blocks", "no-related-blocks"), + SSHTest(listOf("foo-bar"), "no-newline", "append-no-newline", "no-blocks"), if (getOS() == OS.WINDOWS) { SSHTest(listOf("header"), null, "header-command-windows", "blank", """"C:\Program Files\My Header Command\HeaderCommand.exe" --url="%CODER_URL%" --test="foo bar"""") } else { SSHTest(listOf("header"), null, "header-command", "blank", "my-header-command --url=\"\$CODER_URL\" --test=\"foo bar\" --literal='\$CODER_URL'") }, - SSHTest(listOf("foo"), null, "disable-autostart", "blank", null, true, Features(true)), - SSHTest(listOf("foo"), null, "no-disable-autostart", "blank", null, true, Features(false)), + SSHTest(listOf("foo"), null, "disable-autostart", "blank", "", true, Features(true)), + SSHTest(listOf("foo"), null, "no-disable-autostart", "blank", "", true, Features(false)), + SSHTest(listOf("extra"), null, "extra-config", "blank", + extraConfig = extraConfig), + SSHTest(listOf("extra"), null, "extra-config", "blank", + env = Environment(mapOf(CODER_SSH_CONFIG_OPTIONS to extraConfig))), ) val newlineRe = "\r?\n".toRegex() @@ -274,8 +285,10 @@ internal class CoderCLIManagerTest { val settings = CoderSettings(CoderSettingsState( disableAutostart = it.disableAutostart, dataDirectory = tmpdir.resolve("configure-ssh").toString(), - headerCommand = it.headerCommand ?: ""), - sshConfigPath = tmpdir.resolve(it.input + "_to_" + it.output + ".conf")) + headerCommand = it.headerCommand, + sshConfigOptions = it.extraConfig), + sshConfigPath = tmpdir.resolve(it.input + "_to_" + it.output + ".conf"), + env = it.env) val ccm = CoderCLIManager(URL("https://test.coder.invalid"), settings) @@ -295,12 +308,12 @@ internal class CoderCLIManagerTest { .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString())) // Add workspaces. - ccm.configSsh(it.workspaces.toSet(), it.features ?: Features()) + ccm.configSsh(it.workspaces.toSet(), it.features) assertEquals(expectedConf, settings.sshConfigPath.toFile().readText()) // Remove configuration. - ccm.configSsh(emptySet(), it.features ?: Features()) + ccm.configSsh(emptySet(), it.features) // Remove is the configuration we expect after removing. assertEquals( diff --git a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt index b7873e5e..5c692596 100644 --- a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt @@ -170,6 +170,21 @@ internal class CoderSettingsTest { assertEquals(Pair("http://test.gateway.coder.com$expected", "fake-token"), got) } + @Test + fun testSSHConfigOptions() { + var settings = CoderSettings(CoderSettingsState(sshConfigOptions = "ssh config options from state")) + assertEquals("ssh config options from state", settings.sshConfigOptions) + + settings = CoderSettings(CoderSettingsState(), + env = Environment(mapOf(CODER_SSH_CONFIG_OPTIONS to "ssh config options from env"))) + assertEquals("ssh config options from env", settings.sshConfigOptions) + + // State has precedence. + settings = CoderSettings(CoderSettingsState(sshConfigOptions = "ssh config options from state"), + env = Environment(mapOf(CODER_SSH_CONFIG_OPTIONS to "ssh config options from env"))) + assertEquals("ssh config options from state", settings.sshConfigOptions) + } + @Test fun testSettings() { // Make sure the remaining settings are being conveyed.