Skip to content

Use wildcard SSH config Host entries #521

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 3 commits into from
Feb 4, 2025
Merged
Changes from 2 commits
Commits
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
122 changes: 84 additions & 38 deletions src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
@@ -115,6 +115,7 @@
data class Features(
val disableAutostart: Boolean = false,
val reportWorkspaceUsage: Boolean = false,
val wildcardSSH: Boolean = false,
)

/**
@@ -285,37 +286,57 @@
} else {
""
}
val sshOpts = """
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
""".trimIndent()
val blockContent =
if (feats.wildcardSSH) {
startBlock + System.lineSeparator() +
"""
Host ${getHostPrefix()}--*
ProxyCommand ${proxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-- %h
""".trimIndent()
.plus("\n" + sshOpts.prependIndent(" "))
.plus(extraConfig)
.plus("\n\n")
.plus(
"""
Host ${getHostPrefix()}-bg--*
ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-bg-- %h
""".trimIndent()
.plus("\n" + sshOpts.prependIndent(" "))
.plus(extraConfig),
).replace("\n", System.lineSeparator()) +
System.lineSeparator() + endBlock

} else {
workspaceNames.joinToString(
System.lineSeparator(),
startBlock + System.lineSeparator(),
System.lineSeparator() + endBlock,
transform = {
"""
Host ${getHostName(deploymentURL, it.first, currentUser, it.second)}
Host ${getHostName(it.first, currentUser, it.second)}
ProxyCommand ${proxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)}
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
""".trimIndent()
.plus("\n" + sshOpts.prependIndent(" "))
.plus(extraConfig)
.plus("\n")
.plus(
"""
Host ${getBackgroundHostName(deploymentURL, it.first, currentUser, it.second)}
Host ${getBackgroundHostName(it.first, currentUser, it.second)}
ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)}
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
""".trimIndent()
.plus("\n" + sshOpts.prependIndent(" "))
.plus(extraConfig),
).replace("\n", System.lineSeparator())
},
)
}

if (contents == null) {
logger.info("No existing SSH config to modify")
@@ -462,7 +483,7 @@
coderConfigPath.toString(),
"start",
"--yes",
workspaceOwner+"/"+workspaceName,

Check notice on line 486 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

GitHub Actions / Build

String concatenation that can be converted to string template

'String' concatenation can be converted to a template
)
}

@@ -489,40 +510,53 @@
Features(
disableAutostart = version >= SemVer(2, 5, 0),
reportWorkspaceUsage = version >= SemVer(2, 13, 0),
wildcardSSH = version >= SemVer(2, 19, 0),
)
}
}

/*
* This function returns the ssh-host-prefix used for Host entries.
*/
fun getHostPrefix(): String =

Check notice on line 521 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

GitHub Actions / Build

Class member can have 'private' visibility

Function 'getHostPrefix' could be private
"coder-jetbrains-${deploymentURL.safeHost()}"

/**
* This function returns the ssh host name generated for connecting to the workspace.
*/
fun getHostName(
workspace: Workspace,
currentUser: User,
agent: WorkspaceAgent,
): String =
if (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,
// since the user might have recent connections that still use the old syntax.
if (currentUser.username == workspace.ownerName) {
"coder-jetbrains--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}"
} else {
"coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}"
}
}

fun getBackgroundHostName(
workspace: Workspace,
currentUser: User,
agent: WorkspaceAgent,
): String =
if (features.wildcardSSH) {
"${getHostPrefix()}-bg--${workspace.ownerName}--${workspace.name}.${agent.name}"
} else {
getHostName(workspace, currentUser, agent) + "--bg"
}

companion object {
val logger = Logger.getInstance(CoderCLIManager::class.java.simpleName)

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

/**
* This function returns the ssh host name generated for connecting to the workspace.
*/
@JvmStatic
fun getHostName(
url: URL,
workspace: Workspace,
currentUser: User,
agent: WorkspaceAgent,
): String =
// For a user's own workspace, we use the old syntax without a username for backwards compatibility,
// since the user might have recent connections that still use the old syntax.
if (currentUser.username == workspace.ownerName) {
"coder-jetbrains--${workspace.name}.${agent.name}--${url.safeHost()}"
} else {
"coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${url.safeHost()}"
}

fun getBackgroundHostName(
url: URL,
workspace: Workspace,
currentUser: User,
agent: WorkspaceAgent,
): String = getHostName(url, workspace, currentUser, agent) + "--bg"

/**
* This function returns the identifier for the workspace to pass to the
* coder ssh proxy command.
@@ -536,6 +570,18 @@
@JvmStatic
fun getBackgroundHostName(
hostname: String,
): String = hostname + "--bg"
): String {
val parts = hostname.split("--").toMutableList()
if (parts.size < 2) {
throw SSHConfigFormatException("Invalid hostname: $hostname")
}
// non-wildcard case
if (parts[0] == "coder-jetbrains") {
return hostname + "--bg"

Check notice on line 580 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

GitHub Actions / Build

String concatenation that can be converted to string template

'String' concatenation can be converted to a template
}
// wildcard case
parts[0] += "-bg"
return parts.joinToString("--")
}
}
}
8 changes: 6 additions & 2 deletions src/main/kotlin/com/coder/gateway/util/LinkHandler.kt
Original file line number Diff line number Diff line change
@@ -111,7 +111,11 @@ open class LinkHandler(
}

indicator?.invoke("Configuring Coder CLI...")
cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me)
if (cli.features.wildcardSSH) {
cli.configSsh(workspacesAndAgents = emptySet(), currentUser = client.me)
} else {
cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me)
}

val openDialog =
parameters.ideProductCode().isNullOrBlank() ||
@@ -127,7 +131,7 @@ open class LinkHandler(
verifyDownloadLink(parameters)
WorkspaceProjectIDE.fromInputs(
name = CoderCLIManager.getWorkspaceParts(workspace, agent),
hostname = CoderCLIManager.getHostName(deploymentURL.toURL(), workspace, client.me, agent),
hostname = CoderCLIManager(deploymentURL.toURL(), settings).getHostName(workspace, client.me, agent),
projectPath = parameters.folder(),
ideProductCode = parameters.ideProductCode(),
ideBuildNumber = parameters.ideBuildNumber(),
Original file line number Diff line number Diff line change
@@ -208,7 +208,11 @@
logger.info("Configuring Coder CLI...")
cbIDE.renderer = IDECellRenderer("Configuring Coder CLI...")
withContext(Dispatchers.IO) {
data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me)
if (data.cliManager.features.wildcardSSH) {
data.cliManager.configSsh(emptySet(), data.client.me)
} else {
data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me)
}
}

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

if (ComponentValidator.getInstance(tfProject).isEmpty) {
logger.info("Installing remote path validator...")
@@ -269,7 +273,7 @@
)

// Check the provided setting to see if there's a default IDE to set.
val defaultIde = ides.find { it ->

Check notice on line 276 in src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt

GitHub Actions / Build

Redundant lambda arrow

Redundant lambda arrow
// Using contains on the displayable version of the ide means they can be as specific or as vague as they want
// CL 2023.3.6 233.15619.8 -> a specific Clion build
// CL 2023.3.6 -> a specific Clion version
@@ -428,7 +432,7 @@
override fun data(): WorkspaceProjectIDE = withoutNull(cbIDE.selectedItem, state) { selectedIDE, state ->
selectedIDE.withWorkspaceProject(
name = CoderCLIManager.getWorkspaceParts(state.workspace, state.agent),
hostname = CoderCLIManager.getHostName(state.client.url, state.workspace, state.client.me, state.agent),
hostname = CoderCLIManager(state.client.url).getHostName(state.workspace, state.client.me, state.agent),
projectPath = tfProject.text,
deploymentURL = state.client.url,
)
17 changes: 17 additions & 0 deletions src/test/fixtures/outputs/wildcard.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# --- START CODER JETBRAINS test.coder.invalid
Host coder-jetbrains-test.coder.invalid--*
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
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains

Host coder-jetbrains-test.coder.invalid-bg--*
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
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
# --- END CODER JETBRAINS test.coder.invalid
11 changes: 10 additions & 1 deletion src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
@@ -419,6 +419,15 @@ internal class CoderCLIManagerTest {
output = "multiple-agents",
remove = "blank",
),
SSHTest(
listOf(workspace),
input = null,
output = "wildcard",
remove = "blank",
features = Features(
wildcardSSH = true,
),
),
)

val newlineRe = "\r?\n".toRegex()
@@ -804,7 +813,7 @@ internal class CoderCLIManagerTest {
listOf(
Pair("2.5.0", Features(true)),
Pair("2.13.0", Features(true, true)),
Pair("4.9.0", Features(true, true)),
Pair("4.9.0", Features(true, true, true)),
Pair("2.4.9", Features(false)),
Pair("1.0.1", Features(false)),
)

Unchanged files with check annotations Beta

init {
init()
title = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.choose.text", CoderCLIManager.getWorkspaceParts(state.workspace, state.agent))

Check warning on line 41 in src/main/kotlin/com/coder/gateway/util/Dialogs.kt

GitHub Actions / Build

Incorrect string capitalization

String 'Choose IDE and project for workspace {0}' is not properly capitalized. It should have title capitalization
}
override fun show() {
}
private class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentListModel, String>(columnName) {
override fun valueOf(workspace: WorkspaceAgentListModel?): String? = if (workspace == null) {

Check warning on line 913 in src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

GitHub Actions / Build

Redundant nullable return type

'valueOf' always returns non-null type
"Unknown"
} else if (workspace.workspace.outdated) {
"Outdated"