Skip to content

impl: support for using proxies to access Coder REST API #89

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 4 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -2,6 +2,10 @@

## Unreleased

### Added

- support for using proxies. Proxy authentication is not yet supported.

## 0.1.5 - 2025-04-14

### Fixed
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,42 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open
page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable
experience, it’s recommended to ensure the workspace is running prior to initiating the connection.

## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy

This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that
the plugin’s REST client works correctly when routed through it.

We’ll use [mitmproxy](https://mitmproxy.org/) for this — it can act as both an HTTP and SOCKS5 proxy with SSL
interception.

### Install mitmproxy

1. Follow the [mitmproxy Install Guide](https://docs.mitmproxy.org/stable/overview-installation/) steps for your OS.
2. Start the proxy:

```bash

mitmweb --ssl-insecure --set stream_large_bodies="10m"
```

### Configure Mitmproxy

mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other:

1. Open http://127.0.0.1:8081 in browser;
2. Navigate to `Options -> Edit Options`
3. Update the `Mode` field to `regular` in order to activate HTTP/HTTPS or to `socks5`
4. Proxy authentication can be enabled by updating the `proxyauth` to `username:password`

### Configure Proxy in Toolbox

1. Start Toolbox
2. From Toolbox hexagonal menu icon go to `Settings -> Proxy`
3. There are two options, to use system proxy settings or to manually configure the proxy details.
4. If we go manually, add `127.0.0.1` to the host and port `8080` for HTTP/HTTPS or `1080` for SOCKS5.
5. Before authenticating to the Coder deployment we need to tell the plugin where can we find mitmproxy
certificates. In Coder's Settings page, set the `TLS CA path` to `~/.mitmproxy/mitmproxy-ca-cert.pem`

## Releasing

1. Check that the changelog lists all the important changes.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=0.1.5
version=0.2.0
group=com.coder.toolbox
name=coder-toolbox
4 changes: 3 additions & 1 deletion src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.coder.toolbox.util.toURL
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
Expand All @@ -21,7 +22,8 @@ data class CoderToolboxContext(
val logger: Logger,
val i18n: LocalizableStringFactory,
val settingsStore: CoderSettingsStore,
val secrets: CoderSecretsStore
val secrets: CoderSecretsStore,
val proxySettings: ToolboxProxySettings,
) {
/**
* Try to find a URL.
Expand Down
21 changes: 12 additions & 9 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import com.jetbrains.toolbox.api.core.PluginSecretStore
import com.jetbrains.toolbox.api.core.PluginSettingsStore
import com.jetbrains.toolbox.api.core.ServiceLocator
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.core.getService
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension
import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
Expand All @@ -25,15 +27,16 @@ class CoderToolboxExtension : RemoteDevExtension {
val logger = serviceLocator.getService(Logger::class.java)
return CoderRemoteProvider(
CoderToolboxContext(
serviceLocator.getService(ToolboxUi::class.java),
serviceLocator.getService(EnvironmentUiPageManager::class.java),
serviceLocator.getService(EnvironmentStateColorPalette::class.java),
serviceLocator.getService(ClientHelper::class.java),
serviceLocator.getService(CoroutineScope::class.java),
serviceLocator.getService(Logger::class.java),
serviceLocator.getService(LocalizableStringFactory::class.java),
CoderSettingsStore(serviceLocator.getService(PluginSettingsStore::class.java), Environment(), logger),
CoderSecretsStore(serviceLocator.getService(PluginSecretStore::class.java)),
serviceLocator.getService<ToolboxUi>(),
serviceLocator.getService<EnvironmentUiPageManager>(),
serviceLocator.getService<EnvironmentStateColorPalette>(),
serviceLocator.getService<ClientHelper>(),
serviceLocator.getService<CoroutineScope>(),
serviceLocator.getService<Logger>(),
serviceLocator.getService<LocalizableStringFactory>(),
CoderSettingsStore(serviceLocator.getService<PluginSettingsStore>(), Environment(), logger),
CoderSecretsStore(serviceLocator.getService<PluginSecretStore>()),
serviceLocator.getService<ToolboxProxySettings>()
)
)
}
Expand Down
48 changes: 20 additions & 28 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,24 @@ import com.coder.toolbox.util.getArch
import com.coder.toolbox.util.getHeaders
import com.coder.toolbox.util.getOS
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.ProxySelector
import java.net.URL
import java.util.UUID
import javax.net.ssl.X509TrustManager

/**
* Holds proxy information.
*/
data class ProxyValues(
val username: String?,
val password: String?,
val useAuth: Boolean,
val selector: ProxySelector,
)

/**
* An HTTP client that can make requests to the Coder API.
*
* The token can be omitted if some other authentication mechanism is in use.
*/
open class CoderRestClient(
context: CoderToolboxContext,
private val context: CoderToolboxContext,
val url: URL,
val token: String?,
private val proxyValues: ProxyValues? = null,
private val pluginVersion: String = "development",
) {
private val settings = context.settingsStore.readOnly()
Expand Down Expand Up @@ -81,22 +68,27 @@ open class CoderRestClient(
val trustManagers = coderTrustManagers(settings.tls.caPath)
var builder = OkHttpClient.Builder()

if (proxyValues != null) {
builder =
builder
.proxySelector(proxyValues.selector)
.proxyAuthenticator { _, response ->
if (proxyValues.useAuth && proxyValues.username != null && proxyValues.password != null) {
val credentials = Credentials.basic(proxyValues.username, proxyValues.password)
response.request.newBuilder()
.header("Proxy-Authorization", credentials)
.build()
} else {
null
}
}
if (context.proxySettings.getProxy() != null) {
context.logger.debug("proxy: ${context.proxySettings.getProxy()}")
builder.proxy(context.proxySettings.getProxy())
} else if (context.proxySettings.getProxySelector() != null) {
context.logger.debug("proxy selector: ${context.proxySettings.getProxySelector()}")
builder.proxySelector(context.proxySettings.getProxySelector()!!)
}

//TODO - add support for proxy auth. when Toolbox exposes them
// builder.proxyAuthenticator { _, response ->
// if (proxyValues.useAuth && proxyValues.username != null && proxyValues.password != null) {
// val credentials = Credentials.basic(proxyValues.username, proxyValues.password)
// response.request.newBuilder()
// .header("Proxy-Authorization", credentials)
// .build()
// } else {
// null
// }
// }
// }

if (token != null) {
builder = builder.addInterceptor {
it.proceed(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,10 @@ open class CoderProtocolHandler(
if (settings.requireTokenAuth && token == null) { // User aborted.
throw MissingArgumentException("Token is required")
}
// The http client Toolbox gives us is already set up with the
// proxy config, so we do net need to explicitly add it.
val client = CoderRestClient(
context,
deploymentURL.toURL(),
token,
proxyValues = null, // TODO - not sure the above comment applies as we are creating our own http client
PluginManager.pluginInfo.version
)
client.authenticate()
Expand Down
3 changes: 0 additions & 3 deletions src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,10 @@ class ConnectStep(
signInJob = context.cs.launch {
try {
statusField.textState.update { (context.i18n.ptrl("Authenticating to ${url.host}...")) }
// The http client Toolbox gives us is already set up with the
// proxy config, so we do net need to explicitly add it.
val client = CoderRestClient(
context,
url,
token,
proxyValues = null,
PluginManager.pluginInfo.version,
)
// allows interleaving with the back/cancel action
Expand Down
4 changes: 3 additions & 1 deletion src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.coder.toolbox.util.toURL
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
Expand Down Expand Up @@ -68,7 +69,8 @@ internal class CoderCLIManagerTest {
Environment(),
mockk<Logger>(relaxed = true)
),
mockk<CoderSecretsStore>()
mockk<CoderSecretsStore>(),
mockk<ToolboxProxySettings>()
)

/**
Expand Down
59 changes: 39 additions & 20 deletions src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.coder.toolbox.util.sslContextFromPEMs
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
Expand Down Expand Up @@ -51,6 +52,7 @@ import java.nio.file.Path
import java.util.UUID
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
Expand Down Expand Up @@ -104,8 +106,17 @@ class CoderRestClientTest {
mockk<Logger>(relaxed = true),
mockk<LocalizableStringFactory>(),
CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk<Logger>(relaxed = true)),
mockk<CoderSecretsStore>()
)
mockk<CoderSecretsStore>(),
object : ToolboxProxySettings {
override fun getProxy(): Proxy? = null
override fun getProxySelector(): ProxySelector? = null
override fun addProxyChangeListener(listener: Runnable) {
}

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


data class TestWorkspace(var workspace: Workspace, var resources: List<WorkspaceResource>? = emptyList())

Expand Down Expand Up @@ -529,6 +540,7 @@ class CoderRestClientTest {
}

@Test
@Ignore("Until proxy authentication is supported")
fun usesProxy() {
val settings = CoderSettingsStore(pluginTestSettingsStore(), Environment(), context.logger)
val workspaces = listOf(DataGen.workspace("ws1"))
Expand All @@ -545,26 +557,33 @@ class CoderRestClientTest {
val srv2 = mockProxy()
val client =
CoderRestClient(
context.copy(settingsStore = settings),
context.copy(settingsStore = settings, proxySettings = object : ToolboxProxySettings {
override fun getProxy(): Proxy? = null

override fun getProxySelector(): ProxySelector? {
return object : ProxySelector() {
override fun select(uri: URI): List<Proxy> =
listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port)))

override fun connectFailed(
uri: URI,
sa: SocketAddress,
ioe: IOException,
) {
getDefault().connectFailed(uri, sa, ioe)
}
}
}

override fun addProxyChangeListener(listener: Runnable) {
}

override fun removeProxyChangeListener(listener: Runnable) {
}

}),
URL(url1),
"token",
ProxyValues(
"foo",
"bar",
true,
object : ProxySelector() {
override fun select(uri: URI): List<Proxy> =
listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port)))

override fun connectFailed(
uri: URI,
sa: SocketAddress,
ioe: IOException,
) {
getDefault().connectFailed(uri, sa, ioe)
}
},
),
)

assertEquals(workspaces.map { ws -> ws.name }, runBlocking { client.workspaces() }.map { ws -> ws.name })
Expand Down
Loading