Skip to content

Commit 7aa4067

Browse files
authored
impl: support for configuring SSH connection timeout (#256)
Starting with Toolbox 3.3 the SSH connection timeout will be enforced by passing the value to the SSH command. Up until today the Coder plugin relied only on the default value (10 seconds) which was anyway ignored by Toolbox (i.e. it was not passed to the SSH command) On the other hand we generate the SSH config with ConnectTimeout = 0 which effectively means whatever the OS configured. Once Toolbox 3.3 is released the command parameter will take precedence over the SSH config timeout. With his PR there is now a user configurable setting via the UI Settings page which defaults to 10 seconds, and it is passed to both the command line but also to the config file. - resolves https://youtrack.jetbrains.com/issue/TBX-17312
1 parent 53b1362 commit 7aa4067

9 files changed

Lines changed: 122 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- support for configuring the SSH connection timeout, defaults to 10 seconds
8+
59
## 0.8.4 - 2026-01-20
610

711
### Fixed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,10 @@ The following options control the SSH behavior of the Coder CLI.
398398
- `Enable SSH wildcard config` enables or disables wildcard entries in the SSH configuration, which allow generic
399399
rules for matching multiple workspaces.
400400

401+
- `SSH connnection timeout (seconds)` controls how long the SSH client will wait while trying to establish a TCP
402+
connection to the remote host before giving up. Defaults to 0 seconds which means it uses the system’s TCP timeout
403+
settings instead.
404+
401405
- `SSH proxy log directory` directory where SSH proxy logs are written. Useful for debugging SSH connection issues.
402406

403407
- `SSH network metrics directory` directory where network information used by the SSH proxy is stored.

src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ class CoderRemoteEnvironment(
293293
* have to do is provide it a host name.
294294
*/
295295
override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView(
296+
context,
296297
client.url,
297298
cli,
298299
workspace,

src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ class CoderCLIManager(
392392
?.let { "\n" + it.prependIndent(" ") }
393393
?: ""
394394
val options = """
395-
ConnectTimeout 0
395+
ConnectTimeout ${context.settingsStore.sshConnectionTimeoutInSeconds}
396396
StrictHostKeyChecking no
397397
UserKnownHostsFile /dev/null
398398
LogLevel ERROR

src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ interface ReadOnlyCoderSettings {
133133
*/
134134
val isSshWildcardConfigEnabled: Boolean
135135

136+
/**
137+
* Timeout duration in seconds for establishing an SSH connection.
138+
*/
139+
val sshConnectionTimeoutInSeconds: Int
140+
136141
/**
137142
* The location of the SSH config. Defaults to ~/.ssh/config.
138143
*/

src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class CoderSettingsStore(
7070
override val requiresMTlsAuth: Boolean get() = tls.certPath?.isNotBlank() == true && tls.keyPath?.isNotBlank() == true
7171
override val disableAutostart: Boolean
7272
get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC)
73+
override val sshConnectionTimeoutInSeconds: Int
74+
get() = store[SSH_CONNECTION_TIMEOUT_IN_SECONDS]?.toIntOrNull() ?: 0
7375
override val isSshWildcardConfigEnabled: Boolean
7476
get() = store[ENABLE_SSH_WILDCARD_CONFIG]?.toBooleanStrictOrNull() ?: true
7577
override val sshConfigPath: String
@@ -233,6 +235,10 @@ class CoderSettingsStore(
233235
store[DISABLE_AUTOSTART] = shouldDisableAutostart.toString()
234236
}
235237

238+
fun updateSshConnectionTimeoutInSeconds(sshConnectionTimeoutInSeconds: Int) {
239+
store[SSH_CONNECTION_TIMEOUT_IN_SECONDS] = sshConnectionTimeoutInSeconds.toString()
240+
}
241+
236242
fun updateEnableSshWildcardConfig(enable: Boolean) {
237243
store[ENABLE_SSH_WILDCARD_CONFIG] = enable.toString()
238244
}

src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ internal const val TLS_CERT_REFRESH_COMMAND = "tlsCertRefreshCommand"
4040

4141
internal const val DISABLE_AUTOSTART = "disableAutostart"
4242

43+
internal const val SSH_CONNECTION_TIMEOUT_IN_SECONDS = "sshConnectionTimeoutInSeconds"
44+
4345
internal const val ENABLE_SSH_WILDCARD_CONFIG = "enableSshWildcardConfig"
4446

4547
internal const val SSH_CONFIG_PATH = "sshConfigPath"

src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import com.jetbrains.toolbox.api.ui.components.UiField
1515
import kotlinx.coroutines.CoroutineName
1616
import kotlinx.coroutines.Job
1717
import kotlinx.coroutines.channels.Channel
18-
import kotlinx.coroutines.channels.ClosedSendChannelException
1918
import kotlinx.coroutines.flow.MutableStateFlow
2019
import kotlinx.coroutines.flow.StateFlow
2120
import kotlinx.coroutines.flow.update
@@ -69,32 +68,62 @@ class CoderSettingsPage(
6968
)
7069
)
7170

72-
private val enableBinaryDirectoryFallbackField =
73-
CheckboxField(
74-
settings.enableBinaryDirectoryFallback,
75-
context.i18n.ptrl("Enable binary directory fallback")
76-
)
77-
private val headerCommandField =
78-
TextField(context.i18n.ptrl("Header command"), settings.headerCommand ?: "", TextType.General)
79-
private val tlsCertPathField =
80-
TextField(context.i18n.ptrl("TLS cert path"), settings.tls.certPath ?: "", TextType.General)
81-
private val tlsKeyPathField =
82-
TextField(context.i18n.ptrl("TLS key path"), settings.tls.keyPath ?: "", TextType.General)
71+
private val enableBinaryDirectoryFallbackField = CheckboxField(
72+
settings.enableBinaryDirectoryFallback,
73+
context.i18n.ptrl("Enable binary directory fallback")
74+
)
75+
private val headerCommandField = TextField(
76+
context.i18n.ptrl("Header command"),
77+
settings.headerCommand ?: "",
78+
TextType.General
79+
)
80+
81+
private val tlsCertPathField = TextField(
82+
context.i18n.ptrl("TLS cert path"),
83+
settings.tls.certPath ?: "",
84+
TextType.General
85+
)
86+
private val tlsKeyPathField = TextField(
87+
context.i18n.ptrl("TLS key path"),
88+
settings.tls.keyPath ?: "", TextType.General
89+
)
8390
private val tlsCAPathField =
8491
TextField(context.i18n.ptrl("TLS CA path"), settings.tls.caPath ?: "", TextType.General)
8592
private val tlsAlternateHostnameField =
8693
TextField(context.i18n.ptrl("TLS alternate hostname"), settings.tls.altHostname ?: "", TextType.General)
87-
private val disableAutostartField =
88-
CheckboxField(settings.disableAutostart, context.i18n.ptrl("Disable autostart"))
89-
90-
private val enableSshWildCardConfig =
91-
CheckboxField(settings.isSshWildcardConfigEnabled, context.i18n.ptrl("Enable SSH wildcard config"))
92-
private val sshExtraArgs =
93-
TextField(context.i18n.ptrl("Extra SSH options"), settings.sshConfigOptions ?: "", TextType.General)
94-
private val sshLogDirField =
95-
TextField(context.i18n.ptrl("SSH proxy log directory"), settings.sshLogDirectory ?: "", TextType.General)
96-
private val networkInfoDirField =
97-
TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General)
94+
95+
private val disableAutostartField = CheckboxField(
96+
settings.disableAutostart,
97+
context.i18n.ptrl("Disable autostart")
98+
)
99+
100+
private val enableSshWildCardConfig = CheckboxField(
101+
settings.isSshWildcardConfigEnabled,
102+
context.i18n.ptrl("Enable SSH wildcard config")
103+
)
104+
105+
private val sshConnectionTimeoutField = TextField(
106+
context.i18n.ptrl("SSH connection timeout (seconds)"),
107+
settings.sshConnectionTimeoutInSeconds.toString(),
108+
TextType.Integer
109+
)
110+
111+
private val sshExtraArgs = TextField(
112+
context.i18n.ptrl("Extra SSH options"),
113+
settings.sshConfigOptions ?: "",
114+
TextType.General
115+
)
116+
117+
private val sshLogDirField = TextField(
118+
context.i18n.ptrl("SSH proxy log directory"),
119+
settings.sshLogDirectory ?: "",
120+
TextType.General
121+
)
122+
private val networkInfoDirField = TextField(
123+
context.i18n.ptrl("SSH network metrics directory"),
124+
settings.networkInfoDir,
125+
TextType.General
126+
)
98127

99128
private lateinit var visibilityUpdateJob: Job
100129
override val fields: StateFlow<List<UiField>> = MutableStateFlow(
@@ -115,6 +144,7 @@ class CoderSettingsPage(
115144
tlsAlternateHostnameField,
116145
disableAutostartField,
117146
enableSshWildCardConfig,
147+
sshConnectionTimeoutField,
118148
sshLogDirField,
119149
networkInfoDirField,
120150
sshExtraArgs,
@@ -124,36 +154,43 @@ class CoderSettingsPage(
124154
override val actionButtons: StateFlow<List<RunnableActionDescription>> = MutableStateFlow(
125155
listOf(
126156
Action(context, "Save", closesPage = true) {
127-
context.settingsStore.updateBinarySource(binarySourceField.contentState.value)
128-
context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value)
129-
context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value)
130-
context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value)
131-
context.settingsStore.updateUseAppNameAsTitle(useAppNameField.checkedState.value)
132-
context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value)
133-
context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value)
134-
context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value)
135-
context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value)
136-
context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value)
137-
context.settingsStore.updateCertPath(tlsCertPathField.contentState.value)
138-
context.settingsStore.updateKeyPath(tlsKeyPathField.contentState.value)
139-
context.settingsStore.updateCAPath(tlsCAPathField.contentState.value)
140-
context.settingsStore.updateAltHostname(tlsAlternateHostnameField.contentState.value)
141-
context.settingsStore.updateDisableAutostart(disableAutostartField.checkedState.value)
142-
val oldIsSshWildcardConfigEnabled = settings.isSshWildcardConfigEnabled
143-
context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value)
144-
145-
if (enableSshWildCardConfig.checkedState.value != oldIsSshWildcardConfigEnabled) {
146-
context.cs.launch(CoroutineName("SSH Wildcard Setting")) {
147-
try {
157+
with(context.settingsStore) {
158+
updateBinarySource(binarySourceField.contentState.value)
159+
updateBinaryDirectory(binaryDirectoryField.contentState.value)
160+
updateDataDirectory(dataDirectoryField.contentState.value)
161+
updateEnableDownloads(enableDownloadsField.checkedState.value)
162+
updateUseAppNameAsTitle(useAppNameField.checkedState.value)
163+
updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value)
164+
updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value)
165+
updateHttpClientLogLevel(httpLoggingField.selectedValueState.value)
166+
updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value)
167+
updateHeaderCommand(headerCommandField.contentState.value)
168+
updateCertPath(tlsCertPathField.contentState.value)
169+
updateKeyPath(tlsKeyPathField.contentState.value)
170+
updateCAPath(tlsCAPathField.contentState.value)
171+
updateAltHostname(tlsAlternateHostnameField.contentState.value)
172+
updateDisableAutostart(disableAutostartField.checkedState.value)
173+
174+
val sshWildcardEnabled = enableSshWildCardConfig.checkedState.value
175+
val sshTimeout = sshConnectionTimeoutField.contentState.value.toInt()
176+
177+
val sshSettingsChanged = sshWildcardEnabled != settings.isSshWildcardConfigEnabled ||
178+
sshTimeout != settings.sshConnectionTimeoutInSeconds
179+
180+
updateEnableSshWildcardConfig(sshWildcardEnabled)
181+
updateSshConnectionTimeoutInSeconds(sshTimeout)
182+
183+
if (sshSettingsChanged) {
184+
runCatching {
148185
triggerSshConfig.send(true)
149-
context.logger.info("Wildcard settings have been modified from $oldIsSshWildcardConfigEnabled to ${!oldIsSshWildcardConfigEnabled}, ssh config is going to be regenerated...")
150-
} catch (_: ClosedSendChannelException) {
186+
context.logger.info("Settings have been modified, ssh config is going to be regenerated...")
151187
}
152188
}
189+
190+
updateSshLogDir(sshLogDirField.contentState.value)
191+
updateNetworkInfoDir(networkInfoDirField.contentState.value)
192+
updateSshConfigOptions(sshExtraArgs.contentState.value)
153193
}
154-
context.settingsStore.updateSshLogDir(sshLogDirField.contentState.value)
155-
context.settingsStore.updateNetworkInfoDir(networkInfoDirField.contentState.value)
156-
context.settingsStore.updateSshConfigOptions(sshExtraArgs.contentState.value)
157194
}
158195
)
159196
)
@@ -211,6 +248,10 @@ class CoderSettingsPage(
211248
settings.isSshWildcardConfigEnabled
212249
}
213250

251+
sshConnectionTimeoutField.contentState.update {
252+
settings.sshConnectionTimeoutInSeconds.toString()
253+
}
254+
214255
sshExtraArgs.contentState.update {
215256
settings.sshConfigOptions ?: ""
216257
}

src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.coder.toolbox.views
22

3+
import com.coder.toolbox.CoderToolboxContext
34
import com.coder.toolbox.cli.CoderCLIManager
45
import com.coder.toolbox.sdk.v2.models.Workspace
56
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
67
import com.jetbrains.toolbox.api.remoteDev.environments.SshEnvironmentContentsView
78
import com.jetbrains.toolbox.api.remoteDev.ssh.SshConnectionInfo
89
import java.net.URL
10+
import kotlin.time.Duration.Companion.seconds
911

1012
/**
1113
* A view for a single environment. It displays the projects and IDEs.
@@ -16,15 +18,18 @@ import java.net.URL
1618
* SSH must be configured before this will work.
1719
*/
1820
class EnvironmentView(
21+
private val context: CoderToolboxContext,
1922
private val url: URL,
2023
private val cli: CoderCLIManager,
2124
private val workspace: Workspace,
2225
private val agent: WorkspaceAgent,
2326
) : SshEnvironmentContentsView {
24-
override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(url, cli, workspace, agent)
27+
override suspend fun getConnectionInfo(): SshConnectionInfo =
28+
WorkspaceSshConnectionInfo(context, url, cli, workspace, agent)
2529
}
2630

2731
private class WorkspaceSshConnectionInfo(
32+
private val context: CoderToolboxContext,
2833
url: URL,
2934
cli: CoderCLIManager,
3035
private val workspace: Workspace,
@@ -45,6 +50,9 @@ private class WorkspaceSshConnectionInfo(
4550
*/
4651
override val userName: String? = null
4752

53+
override val connectionTimeoutMillis: Long
54+
get() = context.settingsStore.sshConnectionTimeoutInSeconds.seconds.inWholeMilliseconds
55+
4856
override fun equals(other: Any?): Boolean {
4957
if (this === other) return true
5058
if (javaClass != other?.javaClass) return false

0 commit comments

Comments
 (0)