Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- workspaces status is now refresh every time Coder Toolbox becomes visible

### Fixed

- support for downloading the CLI when proxy is configured

## 0.6.2 - 2025-08-14

### Changed
Expand Down
23 changes: 11 additions & 12 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import com.coder.toolbox.cli.gpg.GPGVerifier
import com.coder.toolbox.cli.gpg.VerificationResult
import com.coder.toolbox.cli.gpg.VerificationResult.Failed
import com.coder.toolbox.cli.gpg.VerificationResult.Invalid
import com.coder.toolbox.plugin.PluginManager
import com.coder.toolbox.sdk.CoderHttpClientBuilder
import com.coder.toolbox.sdk.interceptors.Interceptors
import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW
import com.coder.toolbox.util.CoderHostnameVerifier
import com.coder.toolbox.util.InvalidVersionException
import com.coder.toolbox.util.SemVer
import com.coder.toolbox.util.coderSocketFactory
import com.coder.toolbox.util.coderTrustManagers
import com.coder.toolbox.util.escape
import com.coder.toolbox.util.escapeSubcommand
import com.coder.toolbox.util.safeHost
Expand All @@ -29,15 +29,13 @@ import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.zeroturnaround.exec.ProcessExecutor
import retrofit2.Retrofit
import java.io.EOFException
import java.io.FileNotFoundException
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import javax.net.ssl.X509TrustManager

/**
* Version output from the CLI's version command.
Expand Down Expand Up @@ -148,13 +146,14 @@ class CoderCLIManager(
val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config")

private fun createDownloadService(): CoderDownloadService {
val okHttpClient = OkHttpClient.Builder()
.sslSocketFactory(
coderSocketFactory(context.settingsStore.tls),
coderTrustManagers(context.settingsStore.tls.caPath)[0] as X509TrustManager
)
.hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname))
.build()
val interceptors = buildList {
add((Interceptors.userAgent(PluginManager.pluginInfo.version)))
add(Interceptors.logging(context))
}
val okHttpClient = CoderHttpClientBuilder.build(
context,
interceptors
)

val retrofit = Retrofit.Builder()
.baseUrl(deploymentURL.toString())
Expand Down
56 changes: 56 additions & 0 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.coder.toolbox.sdk

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.util.CoderHostnameVerifier
import com.coder.toolbox.util.coderSocketFactory
import com.coder.toolbox.util.coderTrustManagers
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.net.ssl.X509TrustManager

object CoderHttpClientBuilder {
fun build(
context: CoderToolboxContext,
interceptors: List<Interceptor>
): OkHttpClient {
val settings = context.settingsStore.readOnly()

val socketFactory = coderSocketFactory(settings.tls)
val trustManagers = coderTrustManagers(settings.tls.caPath)
var builder = OkHttpClient.Builder()

if (context.proxySettings.getProxy() != null) {
context.logger.info("proxy: ${context.proxySettings.getProxy()}")
builder.proxy(context.proxySettings.getProxy())
} else if (context.proxySettings.getProxySelector() != null) {
context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}")
builder.proxySelector(context.proxySettings.getProxySelector()!!)
}

// Note: This handles only HTTP/HTTPS proxy authentication.
// SOCKS5 proxy authentication is currently not supported due to limitations described in:
// https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0
builder.proxyAuthenticator { _, response ->
val proxyAuth = context.proxySettings.getProxyAuth()
if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) {
return@proxyAuthenticator null
}
val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password)
response.request.newBuilder()
.header("Proxy-Authorization", credentials)
.build()
}

builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
.hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname))
.retryOnConnectionFailure(true)

interceptors.forEach { interceptor ->
builder.addInterceptor(interceptor)

}
return builder.build()
}
}
85 changes: 14 additions & 71 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory
import com.coder.toolbox.sdk.convertors.OSConverter
import com.coder.toolbox.sdk.convertors.UUIDConverter
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.interceptors.LoggingInterceptor
import com.coder.toolbox.sdk.interceptors.Interceptors
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
import com.coder.toolbox.sdk.v2.models.BuildInfo
Expand All @@ -21,23 +21,14 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason
import com.coder.toolbox.sdk.v2.models.WorkspaceResource
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
import com.coder.toolbox.sdk.v2.models.WorkspaceTransition
import com.coder.toolbox.util.CoderHostnameVerifier
import com.coder.toolbox.util.coderSocketFactory
import com.coder.toolbox.util.coderTrustManagers
import com.coder.toolbox.util.getArch
import com.coder.toolbox.util.getHeaders
import com.coder.toolbox.util.getOS
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
import com.squareup.moshi.Moshi
import okhttp3.Credentials
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import javax.net.ssl.X509TrustManager

/**
* An HTTP client that can make requests to the Coder API.
Expand All @@ -50,7 +41,6 @@ open class CoderRestClient(
val token: String?,
private val pluginVersion: String = "development",
) {
private val settings = context.settingsStore.readOnly()
private lateinit var moshi: Moshi
private lateinit var httpClient: OkHttpClient
private lateinit var retroRestClient: CoderV2RestFacade
Expand All @@ -70,69 +60,22 @@ open class CoderRestClient(
.add(OSConverter())
.add(UUIDConverter())
.build()

val socketFactory = coderSocketFactory(settings.tls)
val trustManagers = coderTrustManagers(settings.tls.caPath)
var builder = OkHttpClient.Builder()

if (context.proxySettings.getProxy() != null) {
context.logger.info("proxy: ${context.proxySettings.getProxy()}")
builder.proxy(context.proxySettings.getProxy())
} else if (context.proxySettings.getProxySelector() != null) {
context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}")
builder.proxySelector(context.proxySettings.getProxySelector()!!)
}

// Note: This handles only HTTP/HTTPS proxy authentication.
// SOCKS5 proxy authentication is currently not supported due to limitations described in:
// https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0
builder.proxyAuthenticator { _, response ->
val proxyAuth = context.proxySettings.getProxyAuth()
if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) {
return@proxyAuthenticator null
}
val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password)
response.request.newBuilder()
.header("Proxy-Authorization", credentials)
.build()
}

if (context.settingsStore.requireTokenAuth) {
if (token.isNullOrBlank()) {
throw IllegalStateException("Token is required for $url deployment")
}
builder = builder.addInterceptor {
it.proceed(
it.request().newBuilder().addHeader("Coder-Session-Token", token).build()
)
val interceptors = buildList {
if (context.settingsStore.requireTokenAuth) {
if (token.isNullOrBlank()) {
throw IllegalStateException("Token is required for $url deployment")
}
add(Interceptors.tokenAuth(token))
}
add((Interceptors.userAgent(pluginVersion)))
add(Interceptors.externalHeaders(context, url))
add(Interceptors.logging(context))
}

httpClient =
builder
.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
.hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname))
.retryOnConnectionFailure(true)
.addInterceptor {
it.proceed(
it.request().newBuilder().addHeader(
"User-Agent",
"Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})",
).build(),
)
}
.addInterceptor {
var request = it.request()
val headers = getHeaders(url, settings.headerCommand)
if (headers.isNotEmpty()) {
val reqBuilder = request.newBuilder()
headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) }
request = reqBuilder.build()
}
it.proceed(request)
}
.addInterceptor(LoggingInterceptor(context))
.build()
httpClient = CoderHttpClientBuilder.build(
context,
interceptors
)

retroRestClient =
Retrofit.Builder().baseUrl(url.toString()).client(httpClient)
Expand Down
64 changes: 64 additions & 0 deletions src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.coder.toolbox.sdk.interceptors

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.util.getArch
import com.coder.toolbox.util.getHeaders
import com.coder.toolbox.util.getOS
import okhttp3.Interceptor
import java.net.URL

/**
* Factory of okhttp interceptors
*/
object Interceptors {

/**
* Creates a token authentication interceptor
*/
fun tokenAuth(token: String): Interceptor {
return Interceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.addHeader("Coder-Session-Token", token)
.build()
)
}
}

/**
* Creates a User-Agent header interceptor
*/
fun userAgent(pluginVersion: String): Interceptor {
return Interceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.addHeader("User-Agent", "Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})")
.build()
)
}
}

/**
* Adds headers generated by executing a native command
*/
fun externalHeaders(context: CoderToolboxContext, url: URL): Interceptor {
val settings = context.settingsStore.readOnly()
return Interceptor { chain ->
var request = chain.request()
val headers = getHeaders(url, settings.headerCommand)
if (headers.isNotEmpty()) {
val reqBuilder = request.newBuilder()
headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) }
request = reqBuilder.build()
}
chain.proceed(request)
}
}

/**
* Creates a logging interceptor
*/
fun logging(context: CoderToolboxContext): Interceptor {
return LoggingInterceptor(context)
}
}
25 changes: 18 additions & 7 deletions src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.core.os.LocalDesktopManager
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper
import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
Expand All @@ -52,6 +53,8 @@ import org.zeroturnaround.exec.InvalidExitValueException
import org.zeroturnaround.exec.ProcessInitException
import java.net.HttpURLConnection
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.URI
import java.net.URL
import java.nio.file.AccessDeniedException
Expand Down Expand Up @@ -87,8 +90,17 @@ internal class CoderCLIManagerTest {
mockk<Logger>(relaxed = true)
),
mockk<CoderSecretsStore>(),
mockk<ToolboxProxySettings>()
)
object : ToolboxProxySettings {
override fun getProxy(): Proxy? = null
override fun getProxySelector(): ProxySelector? = null
override fun getProxyAuth(): ProxyAuth? = null

override fun addProxyChangeListener(listener: Runnable) {
}

override fun removeProxyChangeListener(listener: Runnable) {
}
})

@BeforeTest
fun setup() {
Expand Down Expand Up @@ -547,11 +559,10 @@ internal class CoderCLIManagerTest {
context.logger,
)

val ccm =
CoderCLIManager(
context.copy(settingsStore = settings),
it.url ?: URI.create("https://test.coder.invalid").toURL()
)
val ccm = CoderCLIManager(
context.copy(settingsStore = settings),
it.url ?: URI.create("https://test.coder.invalid").toURL()
)

val sshConfigPath = Path.of(settings.sshConfigPath)
// Input is the configuration that we start with, if any.
Expand Down
4 changes: 4 additions & 0 deletions src/test/resources/extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"id": "com.coder.toolbox",
"version": "development"
}
Loading