Skip to content

Commit f39e1f4

Browse files
Use wildcard SSH config Host entries (#521)
* Use wildcard SSH config Host entries This simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns. This can remove minutes from the "Configuring Coder CLI..." step when the user has access to many workspaces (for example, an admin who wants the option of connecting to anyone's workspace on a large deployment). Depends on coder/coder#16088 * changelog update --------- Co-authored-by: Benjamin Peinhardt <[email protected]> Co-authored-by: Benjamin <[email protected]>
1 parent 9705045 commit f39e1f4

File tree

6 files changed

+128
-44
lines changed

6 files changed

+128
-44
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
## Unreleased
66

7+
### Changed
8+
9+
- Simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns.
10+
711
## 2.17.0 - 2025-01-27
812

913
### Added

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

+84-38
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ fun ensureCLI(
115115
data class Features(
116116
val disableAutostart: Boolean = false,
117117
val reportWorkspaceUsage: Boolean = false,
118+
val wildcardSSH: Boolean = false,
118119
)
119120

120121
/**
@@ -285,37 +286,57 @@ class CoderCLIManager(
285286
} else {
286287
""
287288
}
289+
val sshOpts = """
290+
ConnectTimeout 0
291+
StrictHostKeyChecking no
292+
UserKnownHostsFile /dev/null
293+
LogLevel ERROR
294+
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
295+
""".trimIndent()
288296
val blockContent =
297+
if (feats.wildcardSSH) {
298+
startBlock + System.lineSeparator() +
299+
"""
300+
Host ${getHostPrefix()}--*
301+
ProxyCommand ${proxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-- %h
302+
""".trimIndent()
303+
.plus("\n" + sshOpts.prependIndent(" "))
304+
.plus(extraConfig)
305+
.plus("\n\n")
306+
.plus(
307+
"""
308+
Host ${getHostPrefix()}-bg--*
309+
ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-bg-- %h
310+
""".trimIndent()
311+
.plus("\n" + sshOpts.prependIndent(" "))
312+
.plus(extraConfig),
313+
).replace("\n", System.lineSeparator()) +
314+
System.lineSeparator() + endBlock
315+
316+
} else {
289317
workspaceNames.joinToString(
290318
System.lineSeparator(),
291319
startBlock + System.lineSeparator(),
292320
System.lineSeparator() + endBlock,
293321
transform = {
294322
"""
295-
Host ${getHostName(deploymentURL, it.first, currentUser, it.second)}
323+
Host ${getHostName(it.first, currentUser, it.second)}
296324
ProxyCommand ${proxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)}
297-
ConnectTimeout 0
298-
StrictHostKeyChecking no
299-
UserKnownHostsFile /dev/null
300-
LogLevel ERROR
301-
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
302325
""".trimIndent()
326+
.plus("\n" + sshOpts.prependIndent(" "))
303327
.plus(extraConfig)
304328
.plus("\n")
305329
.plus(
306330
"""
307-
Host ${getBackgroundHostName(deploymentURL, it.first, currentUser, it.second)}
331+
Host ${getBackgroundHostName(it.first, currentUser, it.second)}
308332
ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)}
309-
ConnectTimeout 0
310-
StrictHostKeyChecking no
311-
UserKnownHostsFile /dev/null
312-
LogLevel ERROR
313-
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
314333
""".trimIndent()
334+
.plus("\n" + sshOpts.prependIndent(" "))
315335
.plus(extraConfig),
316336
).replace("\n", System.lineSeparator())
317337
},
318338
)
339+
}
319340

320341
if (contents == null) {
321342
logger.info("No existing SSH config to modify")
@@ -489,40 +510,53 @@ class CoderCLIManager(
489510
Features(
490511
disableAutostart = version >= SemVer(2, 5, 0),
491512
reportWorkspaceUsage = version >= SemVer(2, 13, 0),
513+
wildcardSSH = version >= SemVer(2, 19, 0),
492514
)
493515
}
494516
}
495517

518+
/*
519+
* This function returns the ssh-host-prefix used for Host entries.
520+
*/
521+
fun getHostPrefix(): String =
522+
"coder-jetbrains-${deploymentURL.safeHost()}"
523+
524+
/**
525+
* This function returns the ssh host name generated for connecting to the workspace.
526+
*/
527+
fun getHostName(
528+
workspace: Workspace,
529+
currentUser: User,
530+
agent: WorkspaceAgent,
531+
): String =
532+
if (features.wildcardSSH) {
533+
"${getHostPrefix()}--${workspace.ownerName}--${workspace.name}.${agent.name}"
534+
} else {
535+
// For a user's own workspace, we use the old syntax without a username for backwards compatibility,
536+
// since the user might have recent connections that still use the old syntax.
537+
if (currentUser.username == workspace.ownerName) {
538+
"coder-jetbrains--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}"
539+
} else {
540+
"coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}"
541+
}
542+
}
543+
544+
fun getBackgroundHostName(
545+
workspace: Workspace,
546+
currentUser: User,
547+
agent: WorkspaceAgent,
548+
): String =
549+
if (features.wildcardSSH) {
550+
"${getHostPrefix()}-bg--${workspace.ownerName}--${workspace.name}.${agent.name}"
551+
} else {
552+
getHostName(workspace, currentUser, agent) + "--bg"
553+
}
554+
496555
companion object {
497556
val logger = Logger.getInstance(CoderCLIManager::class.java.simpleName)
498557

499558
private val tokenRegex = "--token [^ ]+".toRegex()
500559

501-
/**
502-
* This function returns the ssh host name generated for connecting to the workspace.
503-
*/
504-
@JvmStatic
505-
fun getHostName(
506-
url: URL,
507-
workspace: Workspace,
508-
currentUser: User,
509-
agent: WorkspaceAgent,
510-
): String =
511-
// For a user's own workspace, we use the old syntax without a username for backwards compatibility,
512-
// since the user might have recent connections that still use the old syntax.
513-
if (currentUser.username == workspace.ownerName) {
514-
"coder-jetbrains--${workspace.name}.${agent.name}--${url.safeHost()}"
515-
} else {
516-
"coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${url.safeHost()}"
517-
}
518-
519-
fun getBackgroundHostName(
520-
url: URL,
521-
workspace: Workspace,
522-
currentUser: User,
523-
agent: WorkspaceAgent,
524-
): String = getHostName(url, workspace, currentUser, agent) + "--bg"
525-
526560
/**
527561
* This function returns the identifier for the workspace to pass to the
528562
* coder ssh proxy command.
@@ -536,6 +570,18 @@ class CoderCLIManager(
536570
@JvmStatic
537571
fun getBackgroundHostName(
538572
hostname: String,
539-
): String = hostname + "--bg"
573+
): String {
574+
val parts = hostname.split("--").toMutableList()
575+
if (parts.size < 2) {
576+
throw SSHConfigFormatException("Invalid hostname: $hostname")
577+
}
578+
// non-wildcard case
579+
if (parts[0] == "coder-jetbrains") {
580+
return hostname + "--bg"
581+
}
582+
// wildcard case
583+
parts[0] += "-bg"
584+
return parts.joinToString("--")
585+
}
540586
}
541587
}

src/main/kotlin/com/coder/gateway/util/LinkHandler.kt

+6-2
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,11 @@ open class LinkHandler(
111111
}
112112

113113
indicator?.invoke("Configuring Coder CLI...")
114-
cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me)
114+
if (cli.features.wildcardSSH) {
115+
cli.configSsh(workspacesAndAgents = emptySet(), currentUser = client.me)
116+
} else {
117+
cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me)
118+
}
115119

116120
val openDialog =
117121
parameters.ideProductCode().isNullOrBlank() ||
@@ -127,7 +131,7 @@ open class LinkHandler(
127131
verifyDownloadLink(parameters)
128132
WorkspaceProjectIDE.fromInputs(
129133
name = CoderCLIManager.getWorkspaceParts(workspace, agent),
130-
hostname = CoderCLIManager.getHostName(deploymentURL.toURL(), workspace, client.me, agent),
134+
hostname = CoderCLIManager(deploymentURL.toURL(), settings).getHostName(workspace, client.me, agent),
131135
projectPath = parameters.folder(),
132136
ideProductCode = parameters.ideProductCode(),
133137
ideBuildNumber = parameters.ideBuildNumber(),

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt

+7-3
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,11 @@ class CoderWorkspaceProjectIDEStepView(
208208
logger.info("Configuring Coder CLI...")
209209
cbIDE.renderer = IDECellRenderer("Configuring Coder CLI...")
210210
withContext(Dispatchers.IO) {
211-
data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me)
211+
if (data.cliManager.features.wildcardSSH) {
212+
data.cliManager.configSsh(emptySet(), data.client.me)
213+
} else {
214+
data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me)
215+
}
212216
}
213217

214218
val ides =
@@ -223,7 +227,7 @@ class CoderWorkspaceProjectIDEStepView(
223227
} else {
224228
IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh"))
225229
}
226-
val executor = createRemoteExecutor(CoderCLIManager.getBackgroundHostName(data.client.url, data.workspace, data.client.me, data.agent))
230+
val executor = createRemoteExecutor(CoderCLIManager(data.client.url).getBackgroundHostName(data.workspace, data.client.me, data.agent))
227231

228232
if (ComponentValidator.getInstance(tfProject).isEmpty) {
229233
logger.info("Installing remote path validator...")
@@ -428,7 +432,7 @@ class CoderWorkspaceProjectIDEStepView(
428432
override fun data(): WorkspaceProjectIDE = withoutNull(cbIDE.selectedItem, state) { selectedIDE, state ->
429433
selectedIDE.withWorkspaceProject(
430434
name = CoderCLIManager.getWorkspaceParts(state.workspace, state.agent),
431-
hostname = CoderCLIManager.getHostName(state.client.url, state.workspace, state.client.me, state.agent),
435+
hostname = CoderCLIManager(state.client.url).getHostName(state.workspace, state.client.me, state.agent),
432436
projectPath = tfProject.text,
433437
deploymentURL = state.client.url,
434438
)
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# --- START CODER JETBRAINS test.coder.invalid
2+
Host coder-jetbrains-test.coder.invalid--*
3+
ProxyCommand /tmp/coder-gateway/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-gateway/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --ssh-host-prefix coder-jetbrains-test.coder.invalid-- %h
4+
ConnectTimeout 0
5+
StrictHostKeyChecking no
6+
UserKnownHostsFile /dev/null
7+
LogLevel ERROR
8+
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
9+
10+
Host coder-jetbrains-test.coder.invalid-bg--*
11+
ProxyCommand /tmp/coder-gateway/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-gateway/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --ssh-host-prefix coder-jetbrains-test.coder.invalid-bg-- %h
12+
ConnectTimeout 0
13+
StrictHostKeyChecking no
14+
UserKnownHostsFile /dev/null
15+
LogLevel ERROR
16+
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
17+
# --- END CODER JETBRAINS test.coder.invalid

src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt

+10-1
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,15 @@ internal class CoderCLIManagerTest {
419419
output = "multiple-agents",
420420
remove = "blank",
421421
),
422+
SSHTest(
423+
listOf(workspace),
424+
input = null,
425+
output = "wildcard",
426+
remove = "blank",
427+
features = Features(
428+
wildcardSSH = true,
429+
),
430+
),
422431
)
423432

424433
val newlineRe = "\r?\n".toRegex()
@@ -804,7 +813,7 @@ internal class CoderCLIManagerTest {
804813
listOf(
805814
Pair("2.5.0", Features(true)),
806815
Pair("2.13.0", Features(true, true)),
807-
Pair("4.9.0", Features(true, true)),
816+
Pair("4.9.0", Features(true, true, true)),
808817
Pair("2.4.9", Features(false)),
809818
Pair("1.0.1", Features(false)),
810819
)

0 commit comments

Comments
 (0)