Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- uses: gradle/wrapper-validation-action@v1.1.0

# Run tests
- run: ./gradlew test
- run: ./gradlew test --info

# Collect Tests Result of failed tests
- if: ${{ failure() }}
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## Unreleased

### Added
- Add a path for a command to run to get headers that will be set on all
requests to the Coder deployment.

## 2.6.0 - 2023-09-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
pluginGroup=com.coder.gateway
pluginName=coder-gateway
# SemVer format -> https://semver.org
pluginVersion=2.6.0
pluginVersion=2.7.0
# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
# for insight into build numbers and IntelliJ Platform versions.
pluginSinceBuild=223.7571.70
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
cli.login(client.token)

indicator.text = "Configuring Coder CLI..."
cli.configSsh(workspaces.flatMap { it.toAgentModels() })
cli.configSsh(workspaces.flatMap { it.toAgentModels() }, settings.headerCommand)

// TODO: Ask for these if missing. Maybe we can reuse the second
// step of the wizard? Could also be nice if we automatically used
Expand Down Expand Up @@ -150,7 +150,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
if (token == null) { // User aborted.
throw IllegalArgumentException("Unable to connect to $deploymentURL, $TOKEN is missing")
}
val client = CoderRestClient(deploymentURL, token.first)
val client = CoderRestClient(deploymentURL, token.first, settings.headerCommand)
return try {
Pair(client, client.me().username)
} catch (ex: AuthenticationResponseException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment")
)
}.layout(RowLayout.PARENT_GRID)
row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) {
textField().resizableColumn().align(AlignX.FILL)
.bindText(state::headerCommand)
.comment(
CoderGatewayBundle.message("gateway.connector.settings.header-command.comment")
)
}.layout(RowLayout.PARENT_GRID)
}
}

Expand Down
28 changes: 24 additions & 4 deletions src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ class CoderCLIManager @JvmOverloads constructor(
/**
* Configure SSH to use this binary.
*/
fun configSsh(workspaces: List<WorkspaceAgentModel>) {
writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaces))
@JvmOverloads
fun configSsh(workspaces: List<WorkspaceAgentModel>, headerCommand: String? = null) {
writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaces, headerCommand))
}

/**
Expand All @@ -194,16 +195,35 @@ class CoderCLIManager @JvmOverloads constructor(
}
}

/**
* Escape a command argument by wrapping it in double quotes and escaping
* any double quotes in the argument. For example, echo "test" becomes
* "echo \"test\"".
*/
private fun escape(s: String): String {
return "\"" + s.replace("\"", "\\\"") + "\""
}

/**
* Given an existing SSH config modify it to add or remove the config for
* this deployment and return the modified config or null if it does not
* need to be modified.
*/
private fun modifySSHConfig(contents: String?, workspaces: List<WorkspaceAgentModel>): String? {
private fun modifySSHConfig(
contents: String?,
workspaces: List<WorkspaceAgentModel>,
headerCommand: String?,
): String? {
val host = getSafeHost(deploymentURL)
val startBlock = "# --- START CODER JETBRAINS $host"
val endBlock = "# --- END CODER JETBRAINS $host"
val isRemoving = workspaces.isEmpty()
val proxyArgs = listOfNotNull(
escape(localBinaryPath.toString()),
"--global-config", escape(coderConfigPath.toString()),
if (!headerCommand.isNullOrBlank()) "--header-command" else null,
if (!headerCommand.isNullOrBlank()) escape(headerCommand) else null,
"ssh", "--stdio")
val blockContent = workspaces.joinToString(
System.lineSeparator(),
startBlock + System.lineSeparator(),
Expand All @@ -212,7 +232,7 @@ class CoderCLIManager @JvmOverloads constructor(
"""
Host ${getHostName(deploymentURL, it)}
HostName coder.${it.name}
ProxyCommand "$localBinaryPath" --global-config "$coderConfigPath" ssh --stdio ${it.name}
ProxyCommand ${proxyArgs.joinToString(" ")} ${it.name}
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
Expand Down
58 changes: 55 additions & 3 deletions src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.util.SystemInfo
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.zeroturnaround.exec.ProcessExecutor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.HttpURLConnection.HTTP_CREATED
Expand All @@ -41,16 +42,16 @@ class CoderRestClientService {
*
* @throws [AuthenticationResponseException] if authentication failed.
*/
fun initClientSession(url: URL, token: String): User {
client = CoderRestClient(url, token)
fun initClientSession(url: URL, token: String, headerCommand: String?): User {
client = CoderRestClient(url, token, headerCommand)
me = client.me()
buildVersion = client.buildInfo().version
isReady = true
return me
}
}

class CoderRestClient(var url: URL, var token: String) {
class CoderRestClient(var url: URL, var token: String, var headerCommand: String?) {
private var httpClient: OkHttpClient
private var retroRestClient: CoderV2RestFacade

Expand All @@ -61,6 +62,16 @@ class CoderRestClient(var url: URL, var token: String) {
httpClient = OkHttpClient.Builder()
.addInterceptor { it.proceed(it.request().newBuilder().addHeader("Coder-Session-Token", token).build()) }
.addInterceptor { it.proceed(it.request().newBuilder().addHeader("User-Agent", "Coder Gateway/${pluginVersion.version} (${SystemInfo.getOsNameAndVersion()}; ${SystemInfo.OS_ARCH})").build()) }
.addInterceptor {
var request = it.request()
val headers = getHeaders(url, headerCommand)
if (headers.size > 0) {
val builder = request.newBuilder()
headers.forEach { builder.addHeader(it.key, it.value) }
request = builder.build()
}
it.proceed(request)
}
// this should always be last if we want to see previous interceptors logged
.addInterceptor(HttpLoggingInterceptor().apply { setLevel(HttpLoggingInterceptor.Level.BASIC) })
.build()
Expand Down Expand Up @@ -141,4 +152,45 @@ class CoderRestClient(var url: URL, var token: String) {

return buildResponse.body()!!
}

companion object {
private val newlineRegex = "\r?\n".toRegex()
private val endingNewlineRegex = "\r?\n$".toRegex()

// TODO: This really only needs to be a private function, but
// unfortunately it is not possible to test the client because it fails
// on the plugin manager core call and I do not know how to fix it. So,
// for now make this static and test it directly instead.
@JvmStatic
fun getHeaders(url: URL, headerCommand: String?): Map<String, String> {
if (headerCommand.isNullOrBlank()) {
return emptyMap()
}
val (shell, caller) = when (getOS()) {
OS.WINDOWS -> Pair("cmd.exe", "/c")
else -> Pair("sh", "-c")
}
return ProcessExecutor()
.command(shell, caller, headerCommand)
.environment("CODER_URL", url.toString())
.exitValues(0)
.readOutput(true)
.execute()
.outputUTF8()
.replaceFirst(endingNewlineRegex, "")
.split(newlineRegex)
.associate {
// Header names cannot be blank or contain whitespace and
// the Coder CLI requires that there be an equals sign (the
// value can be blank though). The second case is taken
// care of by the destructure here, as it will throw if
// there are not enough parts.
val (name, value) = it.split("=", limit=2)
if (name.contains(" ") || name == "") {
throw Exception("\"$name\" is not a valid header name")
}
name to value
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class CoderSettingsState : PersistentStateComponent<CoderSettingsState> {
var dataDirectory: String = ""
var enableDownloads: Boolean = true
var enableBinaryDirectoryFallback: Boolean = false
var headerCommand: String = ""
override fun getState(): CoderSettingsState {
return this
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.coder.gateway.sdk.toURL
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
import com.coder.gateway.sdk.v2.models.toAgentModels
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
import com.coder.gateway.services.CoderSettingsState
import com.coder.gateway.toWorkspaceParams
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
Expand Down Expand Up @@ -72,6 +73,7 @@ data class DeploymentInfo(
)

class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : GatewayRecentConnections, Disposable {
private val settings: CoderSettingsState = service()
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
private val cs = CoroutineScope(Dispatchers.Main)

Expand Down Expand Up @@ -259,7 +261,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
deployments[dir] ?: try {
val url = Path.of(dir).resolve("url").readText()
val token = Path.of(dir).resolve("session").readText()
DeploymentInfo(CoderRestClient(url.toURL(), token))
DeploymentInfo(CoderRestClient(url.toURL(), token, settings.headerCommand))
} catch (e: Exception) {
logger.error("Unable to create client from $dir", e)
DeploymentInfo(error = "Error trying to read $dir: ${e.message}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
*/
private fun authenticate(url: URL, token: String) {
logger.info("Authenticating to $url...")
clientService.initClientSession(url, token)
clientService.initClientSession(url, token, settings.headerCommand)

try {
logger.info("Checking compatibility with Coder version ${clientService.buildVersion}...")
Expand Down Expand Up @@ -614,7 +614,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
poller?.cancel()

logger.info("Configuring Coder CLI...")
cli.configSsh(tableOfWorkspaces.items)
cli.configSsh(tableOfWorkspaces.items, settings.headerCommand)

// The config directory can be used to pull the URL and token in
// order to query this workspace's status in other flows, for
Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/messages/CoderGatewayBundle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,8 @@ gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to d
gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \
box will allow the plugin to fall back to the data directory when the CLI \
directory is not writable.
gateway.connector.settings.header-command.title=Header command:
gateway.connector.settings.header-command.comment=An external command that \
outputs additional HTTP headers added to all requests. The command must \
output each header as `key=value` on its own line. The following \
environment variables will be available to the process: CODER_URL.
10 changes: 10 additions & 0 deletions src/test/fixtures/outputs/header-command.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# --- START CODER JETBRAINS test.coder.invalid
Host coder-jetbrains--header--test.coder.invalid
HostName coder.header
ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" --header-command "my-header-command \"test\"" ssh --stdio header
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
# --- END CODER JETBRAINS test.coder.invalid
29 changes: 15 additions & 14 deletions src/test/groovy/CoderCLIManagerTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ class CoderCLIManagerTest extends Specification {
.replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", ccm.localBinaryPath.toString())

when:
ccm.configSsh(workspaces.collect { DataGen.workspace(it) })
ccm.configSsh(workspaces.collect { DataGen.workspace(it) }, headerCommand)

then:
sshConfigPath.toFile().text == expectedConf
Expand All @@ -410,19 +410,20 @@ class CoderCLIManagerTest extends Specification {
sshConfigPath.toFile().text == Path.of("src/test/fixtures/inputs").resolve(remove + ".conf").toFile().text

where:
workspaces | input | output | remove
["foo", "bar"] | null | "multiple-workspaces" | "blank"
["foo-bar"] | "blank" | "append-blank" | "blank"
["foo-bar"] | "blank-newlines" | "append-blank-newlines" | "blank"
["foo-bar"] | "existing-end" | "replace-end" | "no-blocks"
["foo-bar"] | "existing-end-no-newline" | "replace-end-no-newline" | "no-blocks"
["foo-bar"] | "existing-middle" | "replace-middle" | "no-blocks"
["foo-bar"] | "existing-middle-and-unrelated" | "replace-middle-ignore-unrelated" | "no-related-blocks"
["foo-bar"] | "existing-only" | "replace-only" | "blank"
["foo-bar"] | "existing-start" | "replace-start" | "no-blocks"
["foo-bar"] | "no-blocks" | "append-no-blocks" | "no-blocks"
["foo-bar"] | "no-related-blocks" | "append-no-related-blocks" | "no-related-blocks"
["foo-bar"] | "no-newline" | "append-no-newline" | "no-blocks"
workspaces | input | output | remove | headerCommand
["foo", "bar"] | null | "multiple-workspaces" | "blank" | null
["foo-bar"] | "blank" | "append-blank" | "blank" | null
["foo-bar"] | "blank-newlines" | "append-blank-newlines" | "blank" | null
["foo-bar"] | "existing-end" | "replace-end" | "no-blocks" | null
["foo-bar"] | "existing-end-no-newline" | "replace-end-no-newline" | "no-blocks" | null
["foo-bar"] | "existing-middle" | "replace-middle" | "no-blocks" | null
["foo-bar"] | "existing-middle-and-unrelated" | "replace-middle-ignore-unrelated" | "no-related-blocks" | null
["foo-bar"] | "existing-only" | "replace-only" | "blank" | null
["foo-bar"] | "existing-start" | "replace-start" | "no-blocks" | null
["foo-bar"] | "no-blocks" | "append-no-blocks" | "no-blocks" | null
["foo-bar"] | "no-related-blocks" | "append-no-related-blocks" | "no-related-blocks" | null
["foo-bar"] | "no-newline" | "append-no-newline" | "no-blocks" | null
["header"] | null | "header-command" | "blank" | "my-header-command \"test\""
}

def "fails if config is malformed"() {
Expand Down
61 changes: 61 additions & 0 deletions src/test/groovy/CoderRestClientTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.coder.gateway.sdk

import spock.lang.*

@Unroll
class CoderRestClientTest extends Specification {
def "gets headers"() {
expect:
CoderRestClient.getHeaders(new URL("http://localhost"), command) == expected

where:
command | expected
null | [:]
"" | [:]
"printf 'foo=bar\\nbaz=qux'" | ["foo": "bar", "baz": "qux"]
"printf 'foo=bar\\r\\nbaz=qux'" | ["foo": "bar", "baz": "qux"]
"printf 'foo=bar\\r\\n'" | ["foo": "bar"]
"printf 'foo=bar'" | ["foo": "bar"]
"printf 'foo=bar='" | ["foo": "bar="]
"printf 'foo=bar=baz'" | ["foo": "bar=baz"]
"printf 'foo='" | ["foo": ""]
}

def "fails to get headers"() {
when:
CoderRestClient.getHeaders(new URL("http://localhost"), command)

then:
thrown(Exception)

where:
command << [
"printf 'foo=bar\\r\\n\\r\\n'",
"printf '\\r\\nfoo=bar'",
"printf '=foo'",
"printf 'foo'",
"printf ' =foo'",
"printf 'foo =bar'",
"printf 'foo foo=bar'",
"printf ''",
"exit 1",
]
}

@IgnoreIf({ os.windows })
def "has access to environment variables"() {
expect:
CoderRestClient.getHeaders(new URL("http://localhost"), "printf url=\$CODER_URL") == [
"url": "http://localhost",
]
}

@Requires({ os.windows })
def "has access to environment variables"() {
expect:
CoderRestClient.getHeaders(new URL("http://localhost"), "printf url=%CODER_URL%") == [
"url": "http://localhost",
]

}
}