Skip to content

impl: ssh config improvements #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Mar 27, 2025
Merged
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1788632
impl: custom ssh config header block for toolbox plugin
fioan89 Mar 21, 2025
2a86abe
impl: report workspace usage under toolbox
fioan89 Mar 21, 2025
270fa89
impl: change hostname in ssh config to reflect toolbox plugin
fioan89 Mar 21, 2025
d3968ae
fix: update tests with to reflect the changes to the ssh parameters
fioan89 Mar 21, 2025
6a3491d
fix: ssh header block as `JETBRAINS TOOLBOX`
fioan89 Mar 24, 2025
3a8738c
refactor: expose typed settings and secrets store
fioan89 Mar 24, 2025
1da3f4b
impl: don't start the workspace automatically if disable-autostart is…
fioan89 Mar 24, 2025
a104721
fix: force window to show when error dialogs pops-up
fioan89 Mar 24, 2025
d885c60
fix: resiliency when REST call to start the workspace fail
fioan89 Mar 24, 2025
0bdd3ff
fix: resiliency when workspace agent is not ready
fioan89 Mar 24, 2025
2929b0e
refactor: reuse block of code
fioan89 Mar 24, 2025
1708944
impl: rework the settings models
fioan89 Mar 26, 2025
a47e4b2
impl: support for ssh wildcard config
fioan89 Mar 26, 2025
b183af0
fix: missing i18n strings
fioan89 Mar 26, 2025
de8f9f0
impl: change plugin's display name
fioan89 Mar 26, 2025
71cabd1
chore: revert --usage-app
fioan89 Mar 26, 2025
67ec906
fix: test exercising the features available
fioan89 Mar 26, 2025
76bb8b8
fix: test assert raw string paths on different platforms
fioan89 Mar 26, 2025
a53684d
fix: add support for Toolbox 2.6.0.39689
fioan89 Mar 27, 2025
fd860d1
impl: enable wildcard config by default
fioan89 Mar 27, 2025
81eacfb
fix: provide the correct ssh host when wildcard ssh config is enabled
fioan89 Mar 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -71,7 +71,7 @@ val extension = ExtensionJson(

version = properties("version"),
meta = ExtensionJsonMeta(
name = "Coder Toolbox",
name = "Coder",
description = "Connects your JetBrains IDE to Coder workspaces",
vendor = "Coder",
url = "https://github.com/coder/coder-jetbrains-toolbox-plugin",
47 changes: 21 additions & 26 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
@@ -3,10 +3,7 @@ package com.coder.toolbox
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
import com.coder.toolbox.services.CoderSecretsService
import com.coder.toolbox.services.CoderSettingsService
import com.coder.toolbox.settings.CoderSettings
import com.coder.toolbox.settings.Source
import com.coder.toolbox.settings.SettingSource
import com.coder.toolbox.util.CoderProtocolHandler
import com.coder.toolbox.util.DialogUi
import com.coder.toolbox.views.Action
@@ -46,12 +43,11 @@ class CoderRemoteProvider(
private var pollJob: Job? = null
private var lastEnvironments: Set<CoderRemoteEnvironment>? = null

private val cSettings = context.settingsStore.readOnly()

// Create our services from the Toolbox ones.
private val settingsService = CoderSettingsService(context.settingsStore)
private val settings: CoderSettings = CoderSettings(settingsService, context.logger)
private val secrets: CoderSecretsService = CoderSecretsService(context.secretsStore)
private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, settingsService)
private val dialogUi = DialogUi(context, settings)
private val settingsPage: CoderSettingsPage = CoderSettingsPage(context)
private val dialogUi = DialogUi(context)

// The REST client, if we are signed in
private var client: CoderRestClient? = null
@@ -65,7 +61,7 @@ class CoderRemoteProvider(
private var firstRun = true
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: ""))
private val linkHandler = CoderProtocolHandler(context, settings, httpClient, dialogUi, isInitialized)
private val linkHandler = CoderProtocolHandler(context, httpClient, dialogUi, isInitialized)
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(
LoadableState.Value(emptyList())
)
@@ -151,7 +147,7 @@ class CoderRemoteProvider(
private fun logout() {
// Keep the URL and token to make it easy to log back in, but set
// rememberMe to false so we do not try to automatically log in.
secrets.rememberMe = "false"
context.secrets.rememberMe = "false"
close()
}

@@ -272,9 +268,9 @@ class CoderRemoteProvider(
// When coming back to the application, authenticate immediately.
val autologin = shouldDoAutoLogin()
var autologinEx: Exception? = null
secrets.lastToken.let { lastToken ->
secrets.lastDeploymentURL.let { lastDeploymentURL ->
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
context.secrets.lastToken.let { lastToken ->
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !cSettings.requireTokenAuth)) {
try {
return createConnectPage(URL(lastDeploymentURL), lastToken)
} catch (ex: Exception) {
@@ -309,7 +305,7 @@ class CoderRemoteProvider(
return null
}

private fun shouldDoAutoLogin(): Boolean = firstRun && secrets.rememberMe == "true"
private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == "true"

/**
* Create a connect page that starts polling and resets the UI on success.
@@ -318,15 +314,14 @@ class CoderRemoteProvider(
context,
deploymentURL,
token,
settings,
httpClient,
::goToEnvironmentsPage,
) { client, cli ->
// Store the URL and token for use next time.
secrets.lastDeploymentURL = client.url.toString()
secrets.lastToken = client.token ?: ""
context.secrets.lastDeploymentURL = client.url.toString()
context.secrets.lastToken = client.token ?: ""
// Currently we always remember, but this could be made an option.
secrets.rememberMe = "true"
context.secrets.rememberMe = "true"
this.client = client
pollError = null
pollJob?.cancel()
@@ -343,11 +338,11 @@ class CoderRemoteProvider(
* 2. Token on disk for this deployment.
* 3. Global token for Coder, if it matches the deployment.
*/
private fun getToken(deploymentURL: URL): Pair<String, Source>? = secrets.lastToken.let {
if (it.isNotBlank() && secrets.lastDeploymentURL == deploymentURL.toString()) {
it to Source.LAST_USED
private fun getToken(deploymentURL: URL): Pair<String, SettingSource>? = context.secrets.lastToken.let {
if (it.isNotBlank() && context.secrets.lastDeploymentURL == deploymentURL.toString()) {
it to SettingSource.LAST_USED
} else {
settings.token(deploymentURL)
cSettings.token(deploymentURL)
}
}

@@ -361,11 +356,11 @@ class CoderRemoteProvider(
* 3. CODER_URL.
* 4. URL in global cli config.
*/
private fun getDeploymentURL(): Pair<String, Source>? = secrets.lastDeploymentURL.let {
private fun getDeploymentURL(): Pair<String, SettingSource>? = context.secrets.lastDeploymentURL.let {
if (it.isNotBlank()) {
it to Source.LAST_USED
it to SettingSource.LAST_USED
} else {
settings.defaultURL()
context.settingsStore.defaultURL()
}
}
}
8 changes: 4 additions & 4 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.coder.toolbox

import com.jetbrains.toolbox.api.core.PluginSecretStore
import com.jetbrains.toolbox.api.core.PluginSettingsStore
import com.coder.toolbox.store.CoderSecretsStore
import com.coder.toolbox.store.CoderSettingsStore
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
@@ -18,6 +18,6 @@ data class CoderToolboxContext(
val cs: CoroutineScope,
val logger: Logger,
val i18n: LocalizableStringFactory,
val settingsStore: PluginSettingsStore,
val secretsStore: PluginSecretStore
val settingsStore: CoderSettingsStore,
val secrets: CoderSecretsStore
)
8 changes: 6 additions & 2 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.coder.toolbox

import com.coder.toolbox.settings.Environment
import com.coder.toolbox.store.CoderSecretsStore
import com.coder.toolbox.store.CoderSettingsStore
import com.jetbrains.toolbox.api.core.PluginSecretStore
import com.jetbrains.toolbox.api.core.PluginSettingsStore
import com.jetbrains.toolbox.api.core.ServiceLocator
@@ -20,6 +23,7 @@ import okhttp3.OkHttpClient
class CoderToolboxExtension : RemoteDevExtension {
// All services must be passed in here and threaded as necessary.
override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider {
val logger = serviceLocator.getService(Logger::class.java)
return CoderRemoteProvider(
CoderToolboxContext(
serviceLocator.getService(ToolboxUi::class.java),
@@ -29,8 +33,8 @@ class CoderToolboxExtension : RemoteDevExtension {
serviceLocator.getService(CoroutineScope::class.java),
serviceLocator.getService(Logger::class.java),
serviceLocator.getService(LocalizableStringFactory::class.java),
serviceLocator.getService(PluginSettingsStore::class.java),
serviceLocator.getService(PluginSecretStore::class.java),
CoderSettingsStore(serviceLocator.getService(PluginSettingsStore::class.java), Environment(), logger),
CoderSecretsStore(serviceLocator.getService(PluginSecretStore::class.java)),
),
OkHttpClient(),
)
83 changes: 56 additions & 27 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
@@ -5,7 +5,6 @@ import com.coder.toolbox.cli.ex.MissingVersionException
import com.coder.toolbox.cli.ex.ResponseException
import com.coder.toolbox.cli.ex.SSHConfigFormatException
import com.coder.toolbox.settings.CoderSettings
import com.coder.toolbox.settings.CoderSettingsState
import com.coder.toolbox.util.CoderHostnameVerifier
import com.coder.toolbox.util.InvalidVersionException
import com.coder.toolbox.util.OS
@@ -59,8 +58,8 @@ fun ensureCLI(
context: CoderToolboxContext,
deploymentURL: URL,
buildVersion: String,
settings: CoderSettings,
): CoderCLIManager {
val settings = context.settingsStore.readOnly()
val cli = CoderCLIManager(deploymentURL, context.logger, settings)

// Short-circuit if we already have the expected version. This
@@ -113,6 +112,7 @@ fun ensureCLI(
data class Features(
val disableAutostart: Boolean = false,
val reportWorkspaceUsage: Boolean = false,
val wildcardSsh: Boolean = false,
)

/**
@@ -123,7 +123,7 @@ class CoderCLIManager(
private val deploymentURL: URL,
private val logger: Logger,
// Plugin configuration.
private val settings: CoderSettings = CoderSettings(CoderSettingsState(), logger),
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.
forceDownloadToData: Boolean = false,
@@ -138,7 +138,7 @@ class CoderCLIManager(
fun download(): Boolean {
val eTag = getBinaryETag()
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
if (settings.headerCommand.isNotBlank()) {
if (!settings.headerCommand.isNullOrBlank()) {
val headersFromHeaderCommand = getHeaders(deploymentURL, settings.headerCommand)
for ((key, value) in headersFromHeaderCommand) {
conn.setRequestProperty(key, value)
@@ -232,7 +232,7 @@ class CoderCLIManager(
* Return the contents of the SSH config or null if it does not exist.
*/
private fun readSSHConfig(): String? = try {
settings.sshConfigPath.toFile().readText()
Path.of(settings.sshConfigPath).toFile().readText()
} catch (e: FileNotFoundException) {
null
}
@@ -251,8 +251,8 @@ class CoderCLIManager(
feats: Features,
): String? {
val host = deploymentURL.safeHost()
val startBlock = "# --- START CODER JETBRAINS $host"
val endBlock = "# --- END CODER JETBRAINS $host"
val startBlock = "# --- START CODER JETBRAINS TOOLBOX $host"
val endBlock = "# --- END CODER JETBRAINS TOOLBOX $host"
val isRemoving = workspaceNames.isEmpty()
val baseArgs =
listOfNotNull(
@@ -264,26 +264,56 @@ class CoderCLIManager(
// always use the correct URL.
"--url",
escape(deploymentURL.toString()),
if (settings.headerCommand.isNotBlank()) "--header-command" else null,
if (settings.headerCommand.isNotBlank()) escapeSubcommand(settings.headerCommand) else null,
if (!settings.headerCommand.isNullOrBlank()) "--header-command" else null,
if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand) else null,
"ssh",
"--stdio",
if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null,
)
val proxyArgs = baseArgs + listOfNotNull(
if (settings.sshLogDirectory.isNotBlank()) "--log-dir" else null,
if (settings.sshLogDirectory.isNotBlank()) escape(settings.sshLogDirectory) else null,
if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null,
if (!settings.sshLogDirectory.isNullOrBlank()) 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 extraConfig =
if (settings.sshConfigOptions.isNotBlank()) {
if (!settings.sshConfigOptions.isNullOrBlank()) {
"\n" + settings.sshConfigOptions.prependIndent(" ")
} else {
""
}
val blockContent =
val options = """
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
""".trimIndent()

val blockContent = if (settings.isSshWildcardConfigEnabled && feats.wildcardSsh) {
startBlock + System.lineSeparator() +
"""
Host ${getWildcardHost(deploymentURL)}--*
ProxyCommand ${proxyArgs.joinToString(" ")} --ssh-host-prefix ${getWildcardHost(deploymentURL)}-- %h
""".trimIndent()
.plus("\n" + options.prependIndent(" "))
.plus(extraConfig)
.plus("\n\n")
.plus(
"""
Host ${getWildcardHost(deploymentURL)}-bg--*
ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${
getWildcardHost(
deploymentURL
)
}-bg-- %h
""".trimIndent()
.plus("\n" + options.prependIndent(" "))
.plus(extraConfig),
).replace("\n", System.lineSeparator()) +
System.lineSeparator() + endBlock
} else {
workspaceNames.joinToString(
System.lineSeparator(),
startBlock + System.lineSeparator(),
@@ -292,28 +322,21 @@ class CoderCLIManager(
"""
Host ${getHostName(deploymentURL, it)}
ProxyCommand ${proxyArgs.joinToString(" ")} $it
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
""".trimIndent()
.plus("\n" + options.prependIndent(" "))
.plus(extraConfig)
.plus("\n")
.plus(
"""
Host ${getBackgroundHostName(deploymentURL, it)}
ProxyCommand ${backgroundProxyArgs.joinToString(" ")} $it
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
""".trimIndent()
.plus("\n" + options.prependIndent(" "))
.plus(extraConfig),
).replace("\n", System.lineSeparator())
},
)
}

if (contents == null) {
logger.info("No existing SSH config to modify")
@@ -379,10 +402,13 @@ class CoderCLIManager(
*/
private fun writeSSHConfig(contents: String?) {
if (contents != null) {
settings.sshConfigPath.parent.toFile().mkdirs()
settings.sshConfigPath.toFile().writeText(contents)
if (!settings.sshConfigPath.isNullOrBlank()) {
val sshConfPath = Path.of(settings.sshConfigPath)
sshConfPath.parent.toFile().mkdirs()
sshConfPath.toFile().writeText(contents)
}
// The Coder cli will *not* create the log directory.
if (settings.sshLogDirectory.isNotBlank()) {
if (!settings.sshLogDirectory.isNullOrBlank()) {
Path.of(settings.sshLogDirectory).toFile().mkdirs()
}
}
@@ -473,18 +499,21 @@ class CoderCLIManager(
Features(
disableAutostart = version >= SemVer(2, 5, 0),
reportWorkspaceUsage = version >= SemVer(2, 13, 0),
version >= SemVer(2, 19, 0),
)
}
}

companion object {
private val tokenRegex = "--token [^ ]+".toRegex()

fun getWildcardHost(url: URL): String = "coder-jetbrains-toolbox--${url.safeHost()}"

@JvmStatic
fun getHostName(
url: URL,
workspaceName: String,
): String = "coder-jetbrains--$workspaceName--${url.safeHost()}"
): String = "coder-jetbrains-toolbox--$workspaceName--${url.safeHost()}"

@JvmStatic
fun getBackgroundHostName(
4 changes: 1 addition & 3 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
@@ -15,8 +15,6 @@ import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceBuild
import com.coder.toolbox.sdk.v2.models.WorkspaceResource
import com.coder.toolbox.sdk.v2.models.WorkspaceTransition
import com.coder.toolbox.settings.CoderSettings
import com.coder.toolbox.settings.CoderSettingsState
import com.coder.toolbox.util.CoderHostnameVerifier
import com.coder.toolbox.util.coderSocketFactory
import com.coder.toolbox.util.coderTrustManagers
@@ -53,11 +51,11 @@ open class CoderRestClient(
context: CoderToolboxContext,
val url: URL,
val token: String?,
private val settings: CoderSettings = CoderSettings(CoderSettingsState(), context.logger),
private val proxyValues: ProxyValues? = null,
private val pluginVersion: String = "development",
existingHttpClient: OkHttpClient? = null,
) {
private val settings = context.settingsStore.readOnly()
private val httpClient: OkHttpClient
private val retroRestClient: CoderV2RestFacade

Loading