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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy