Skip to content

Commit acd0578

Browse files
authored
fix: support for downloading the CLI when proxy is configured (#177)
Until this commit, the CLI download manager relied on a separately configured HTTP client that lacked proxy support, unlike the REST client which was refactored and modularized. Now we have the same support for proxy and a proper user agent and custom logging interceptor.
1 parent 6ab431e commit acd0578

File tree

8 files changed

+192
-90
lines changed

8 files changed

+192
-90
lines changed

CHANGELOG.md

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

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

9+
### Fixed
10+
11+
- support for downloading the CLI when proxy is configured
12+
913
## 0.6.2 - 2025-08-14
1014

1115
### Changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,27 @@ mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5
220220
>
221221
in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0
222222

223+
### Mitmproxy returns 502 Bad Gateway to the client
224+
225+
When running traffic through mitmproxy, you may encounter 502 Bad Gateway errors that mention HTTP/2 protocol error: *
226+
*Received header value surrounded by whitespace**.
227+
This happens because some upstream servers (including dev.coder.com) send back headers such as Content-Security-Policy
228+
with leading or trailing spaces.
229+
While browsers and many HTTP clients accept these headers, mitmproxy enforces the stricter HTTP/2 and HTTP/1.1 RFCs,
230+
which forbid whitespace around header values.
231+
As a result, mitmproxy rejects the response and surfaces a 502 to the client.
232+
233+
The workaround is to disable HTTP/2 in mitmproxy and force HTTP/1.1 on both the client and upstream sides. This avoids
234+
the strict header validation path and allows
235+
mitmproxy to pass responses through unchanged. You can do this by starting mitmproxy with:
236+
237+
```bash
238+
mitmproxy --set http2=false --set upstream_http_version=HTTP/1.1
239+
```
240+
241+
This ensures coder toolbox http client ↔ mitmproxy ↔ server connections all run over HTTP/1.1, preventing the whitespace
242+
error.
243+
223244
## Debugging and Reporting issues
224245

225246
Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH

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

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import com.coder.toolbox.cli.gpg.GPGVerifier
1212
import com.coder.toolbox.cli.gpg.VerificationResult
1313
import com.coder.toolbox.cli.gpg.VerificationResult.Failed
1414
import com.coder.toolbox.cli.gpg.VerificationResult.Invalid
15+
import com.coder.toolbox.plugin.PluginManager
16+
import com.coder.toolbox.sdk.CoderHttpClientBuilder
17+
import com.coder.toolbox.sdk.interceptors.Interceptors
1518
import com.coder.toolbox.sdk.v2.models.Workspace
1619
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
1720
import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW
18-
import com.coder.toolbox.util.CoderHostnameVerifier
1921
import com.coder.toolbox.util.InvalidVersionException
2022
import com.coder.toolbox.util.SemVer
21-
import com.coder.toolbox.util.coderSocketFactory
22-
import com.coder.toolbox.util.coderTrustManagers
2323
import com.coder.toolbox.util.escape
2424
import com.coder.toolbox.util.escapeSubcommand
2525
import com.coder.toolbox.util.safeHost
@@ -29,15 +29,13 @@ import com.squareup.moshi.JsonDataException
2929
import com.squareup.moshi.Moshi
3030
import kotlinx.coroutines.Dispatchers
3131
import kotlinx.coroutines.withContext
32-
import okhttp3.OkHttpClient
3332
import org.zeroturnaround.exec.ProcessExecutor
3433
import retrofit2.Retrofit
3534
import java.io.EOFException
3635
import java.io.FileNotFoundException
3736
import java.net.URL
3837
import java.nio.file.Files
3938
import java.nio.file.Path
40-
import javax.net.ssl.X509TrustManager
4139

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

150148
private fun createDownloadService(): CoderDownloadService {
151-
val okHttpClient = OkHttpClient.Builder()
152-
.sslSocketFactory(
153-
coderSocketFactory(context.settingsStore.tls),
154-
coderTrustManagers(context.settingsStore.tls.caPath)[0] as X509TrustManager
155-
)
156-
.hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname))
157-
.build()
149+
val interceptors = buildList {
150+
add((Interceptors.userAgent(PluginManager.pluginInfo.version)))
151+
add(Interceptors.logging(context))
152+
}
153+
val okHttpClient = CoderHttpClientBuilder.build(
154+
context,
155+
interceptors
156+
)
158157

159158
val retrofit = Retrofit.Builder()
160159
.baseUrl(deploymentURL.toString())
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.coder.toolbox.sdk
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.util.CoderHostnameVerifier
5+
import com.coder.toolbox.util.coderSocketFactory
6+
import com.coder.toolbox.util.coderTrustManagers
7+
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
8+
import okhttp3.Credentials
9+
import okhttp3.Interceptor
10+
import okhttp3.OkHttpClient
11+
import javax.net.ssl.X509TrustManager
12+
13+
object CoderHttpClientBuilder {
14+
fun build(
15+
context: CoderToolboxContext,
16+
interceptors: List<Interceptor>
17+
): OkHttpClient {
18+
val settings = context.settingsStore.readOnly()
19+
20+
val socketFactory = coderSocketFactory(settings.tls)
21+
val trustManagers = coderTrustManagers(settings.tls.caPath)
22+
var builder = OkHttpClient.Builder()
23+
24+
if (context.proxySettings.getProxy() != null) {
25+
context.logger.info("proxy: ${context.proxySettings.getProxy()}")
26+
builder.proxy(context.proxySettings.getProxy())
27+
} else if (context.proxySettings.getProxySelector() != null) {
28+
context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}")
29+
builder.proxySelector(context.proxySettings.getProxySelector()!!)
30+
}
31+
32+
// Note: This handles only HTTP/HTTPS proxy authentication.
33+
// SOCKS5 proxy authentication is currently not supported due to limitations described in:
34+
// https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0
35+
builder.proxyAuthenticator { _, response ->
36+
val proxyAuth = context.proxySettings.getProxyAuth()
37+
if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) {
38+
return@proxyAuthenticator null
39+
}
40+
val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password)
41+
response.request.newBuilder()
42+
.header("Proxy-Authorization", credentials)
43+
.build()
44+
}
45+
46+
builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
47+
.hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname))
48+
.retryOnConnectionFailure(true)
49+
50+
interceptors.forEach { interceptor ->
51+
builder.addInterceptor(interceptor)
52+
53+
}
54+
return builder.build()
55+
}
56+
}

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 14 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory
77
import com.coder.toolbox.sdk.convertors.OSConverter
88
import com.coder.toolbox.sdk.convertors.UUIDConverter
99
import com.coder.toolbox.sdk.ex.APIResponseException
10-
import com.coder.toolbox.sdk.interceptors.LoggingInterceptor
10+
import com.coder.toolbox.sdk.interceptors.Interceptors
1111
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
1212
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
1313
import com.coder.toolbox.sdk.v2.models.BuildInfo
@@ -21,23 +21,14 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason
2121
import com.coder.toolbox.sdk.v2.models.WorkspaceResource
2222
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
2323
import com.coder.toolbox.sdk.v2.models.WorkspaceTransition
24-
import com.coder.toolbox.util.CoderHostnameVerifier
25-
import com.coder.toolbox.util.coderSocketFactory
26-
import com.coder.toolbox.util.coderTrustManagers
27-
import com.coder.toolbox.util.getArch
28-
import com.coder.toolbox.util.getHeaders
29-
import com.coder.toolbox.util.getOS
30-
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
3124
import com.squareup.moshi.Moshi
32-
import okhttp3.Credentials
3325
import okhttp3.OkHttpClient
3426
import retrofit2.Response
3527
import retrofit2.Retrofit
3628
import retrofit2.converter.moshi.MoshiConverterFactory
3729
import java.net.HttpURLConnection
3830
import java.net.URL
3931
import java.util.UUID
40-
import javax.net.ssl.X509TrustManager
4132

4233
/**
4334
* An HTTP client that can make requests to the Coder API.
@@ -50,7 +41,6 @@ open class CoderRestClient(
5041
val token: String?,
5142
private val pluginVersion: String = "development",
5243
) {
53-
private val settings = context.settingsStore.readOnly()
5444
private lateinit var moshi: Moshi
5545
private lateinit var httpClient: OkHttpClient
5646
private lateinit var retroRestClient: CoderV2RestFacade
@@ -70,69 +60,22 @@ open class CoderRestClient(
7060
.add(OSConverter())
7161
.add(UUIDConverter())
7262
.build()
73-
74-
val socketFactory = coderSocketFactory(settings.tls)
75-
val trustManagers = coderTrustManagers(settings.tls.caPath)
76-
var builder = OkHttpClient.Builder()
77-
78-
if (context.proxySettings.getProxy() != null) {
79-
context.logger.info("proxy: ${context.proxySettings.getProxy()}")
80-
builder.proxy(context.proxySettings.getProxy())
81-
} else if (context.proxySettings.getProxySelector() != null) {
82-
context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}")
83-
builder.proxySelector(context.proxySettings.getProxySelector()!!)
84-
}
85-
86-
// Note: This handles only HTTP/HTTPS proxy authentication.
87-
// SOCKS5 proxy authentication is currently not supported due to limitations described in:
88-
// https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0
89-
builder.proxyAuthenticator { _, response ->
90-
val proxyAuth = context.proxySettings.getProxyAuth()
91-
if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) {
92-
return@proxyAuthenticator null
93-
}
94-
val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password)
95-
response.request.newBuilder()
96-
.header("Proxy-Authorization", credentials)
97-
.build()
98-
}
99-
100-
if (context.settingsStore.requireTokenAuth) {
101-
if (token.isNullOrBlank()) {
102-
throw IllegalStateException("Token is required for $url deployment")
103-
}
104-
builder = builder.addInterceptor {
105-
it.proceed(
106-
it.request().newBuilder().addHeader("Coder-Session-Token", token).build()
107-
)
63+
val interceptors = buildList {
64+
if (context.settingsStore.requireTokenAuth) {
65+
if (token.isNullOrBlank()) {
66+
throw IllegalStateException("Token is required for $url deployment")
67+
}
68+
add(Interceptors.tokenAuth(token))
10869
}
70+
add((Interceptors.userAgent(pluginVersion)))
71+
add(Interceptors.externalHeaders(context, url))
72+
add(Interceptors.logging(context))
10973
}
11074

111-
httpClient =
112-
builder
113-
.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
114-
.hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname))
115-
.retryOnConnectionFailure(true)
116-
.addInterceptor {
117-
it.proceed(
118-
it.request().newBuilder().addHeader(
119-
"User-Agent",
120-
"Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})",
121-
).build(),
122-
)
123-
}
124-
.addInterceptor {
125-
var request = it.request()
126-
val headers = getHeaders(url, settings.headerCommand)
127-
if (headers.isNotEmpty()) {
128-
val reqBuilder = request.newBuilder()
129-
headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) }
130-
request = reqBuilder.build()
131-
}
132-
it.proceed(request)
133-
}
134-
.addInterceptor(LoggingInterceptor(context))
135-
.build()
75+
httpClient = CoderHttpClientBuilder.build(
76+
context,
77+
interceptors
78+
)
13679

13780
retroRestClient =
13881
Retrofit.Builder().baseUrl(url.toString()).client(httpClient)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.coder.toolbox.sdk.interceptors
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.util.getArch
5+
import com.coder.toolbox.util.getHeaders
6+
import com.coder.toolbox.util.getOS
7+
import okhttp3.Interceptor
8+
import java.net.URL
9+
10+
/**
11+
* Factory of okhttp interceptors
12+
*/
13+
object Interceptors {
14+
15+
/**
16+
* Creates a token authentication interceptor
17+
*/
18+
fun tokenAuth(token: String): Interceptor {
19+
return Interceptor { chain ->
20+
chain.proceed(
21+
chain.request().newBuilder()
22+
.addHeader("Coder-Session-Token", token)
23+
.build()
24+
)
25+
}
26+
}
27+
28+
/**
29+
* Creates a User-Agent header interceptor
30+
*/
31+
fun userAgent(pluginVersion: String): Interceptor {
32+
return Interceptor { chain ->
33+
chain.proceed(
34+
chain.request().newBuilder()
35+
.addHeader("User-Agent", "Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})")
36+
.build()
37+
)
38+
}
39+
}
40+
41+
/**
42+
* Adds headers generated by executing a native command
43+
*/
44+
fun externalHeaders(context: CoderToolboxContext, url: URL): Interceptor {
45+
val settings = context.settingsStore.readOnly()
46+
return Interceptor { chain ->
47+
var request = chain.request()
48+
val headers = getHeaders(url, settings.headerCommand)
49+
if (headers.isNotEmpty()) {
50+
val reqBuilder = request.newBuilder()
51+
headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) }
52+
request = reqBuilder.build()
53+
}
54+
chain.proceed(request)
55+
}
56+
}
57+
58+
/**
59+
* Creates a logging interceptor
60+
*/
61+
fun logging(context: CoderToolboxContext): Interceptor {
62+
return LoggingInterceptor(context)
63+
}
64+
}

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger
3535
import com.jetbrains.toolbox.api.core.os.LocalDesktopManager
3636
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
3737
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
38+
import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth
3839
import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper
3940
import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
4041
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
@@ -52,6 +53,8 @@ import org.zeroturnaround.exec.InvalidExitValueException
5253
import org.zeroturnaround.exec.ProcessInitException
5354
import java.net.HttpURLConnection
5455
import java.net.InetSocketAddress
56+
import java.net.Proxy
57+
import java.net.ProxySelector
5558
import java.net.URI
5659
import java.net.URL
5760
import java.nio.file.AccessDeniedException
@@ -87,8 +90,17 @@ internal class CoderCLIManagerTest {
8790
mockk<Logger>(relaxed = true)
8891
),
8992
mockk<CoderSecretsStore>(),
90-
mockk<ToolboxProxySettings>()
91-
)
93+
object : ToolboxProxySettings {
94+
override fun getProxy(): Proxy? = null
95+
override fun getProxySelector(): ProxySelector? = null
96+
override fun getProxyAuth(): ProxyAuth? = null
97+
98+
override fun addProxyChangeListener(listener: Runnable) {
99+
}
100+
101+
override fun removeProxyChangeListener(listener: Runnable) {
102+
}
103+
})
92104

93105
@BeforeTest
94106
fun setup() {
@@ -547,11 +559,10 @@ internal class CoderCLIManagerTest {
547559
context.logger,
548560
)
549561

550-
val ccm =
551-
CoderCLIManager(
552-
context.copy(settingsStore = settings),
553-
it.url ?: URI.create("https://test.coder.invalid").toURL()
554-
)
562+
val ccm = CoderCLIManager(
563+
context.copy(settingsStore = settings),
564+
it.url ?: URI.create("https://test.coder.invalid").toURL()
565+
)
555566

556567
val sshConfigPath = Path.of(settings.sshConfigPath)
557568
// Input is the configuration that we start with, if any.

0 commit comments

Comments
 (0)