diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a55c794..e6e4b79b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,9 +20,9 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 @@ -35,7 +35,7 @@ jobs: # Collect Tests Result of failed tests - if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: tests-result path: ${{ github.workspace }}/build/reports/tests @@ -50,11 +50,11 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6 # Setup Java 21 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 @@ -79,13 +79,13 @@ jobs: # Store already-built plugin as an artifact for downloading - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: zip-artifacts path: ./build/distributions/*.zip - name: Upload Release Notes - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-notes path: RELEASE_NOTES.md @@ -101,7 +101,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6 # Remove old release drafts by using GitHub CLI - name: Remove Old Release Drafts @@ -113,7 +113,7 @@ jobs: | xargs -I '{}' gh api -X DELETE repos/${{ github.repository }}/releases/{} - name: Download Build Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: zip-artifacts path: artifacts/ @@ -121,7 +121,7 @@ jobs: run: ls -R artifacts/ - name: Download Release Notes - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: release-notes path: notes/ diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml index d1d20195..d41b69f8 100644 --- a/.github/workflows/jetbrains-compliance.yml +++ b/.github/workflows/jetbrains-compliance.yml @@ -13,10 +13,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' @@ -40,7 +40,7 @@ jobs: ./gradlew detekt - name: Upload detekt reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() with: name: detekt-reports @@ -50,7 +50,7 @@ jobs: - name: Comment PR with compliance status if: github.event_name == 'pull_request' && failure() - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | github.rest.issues.createComment({ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44fb3be0..2d8ca658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,13 +15,13 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6 with: ref: ${{ github.event.release.tag_name }} # Setup Java 21 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 diff --git a/CHANGELOG.md b/CHANGELOG.md index faf43dc1..de79e057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,100 @@ ## Unreleased +## 0.8.1 - 2025-12-11 + +### Changed + +- streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience + +### Fixed + +- URI handling on Linux can now launch IDEs on newly started workspaces + +## 0.8.0 - 2025-12-03 + +### Added + +- application name can now be displayed as the main title page instead of the URL +- automatic mTLS certificate regeneration and retry mechanism + +### Changed + +- workspaces are now started with the help of the CLI + +## 0.7.2 - 2025-11-03 + +### Changed + +- URI handling no longer waits for confirmation to use latest build if the provided build number is too old + +### Fixed + +- IDE is now launched when URI is handled by an already running Toolbox instance. + +## 0.7.1 - 2025-10-13 + +### Fixed + +- potential race condition that could cause crashes when settings are modified concurrently +- CLI download on some Windows versions + +## 0.7.0 - 2025-09-27 + +### Changed + +- simplified storage for last used url and token + +## 0.6.6 - 2025-09-24 + +### Changed + +- workspaces can no longer be removed by accident - users are now required to input the workspace name. + +### Fixed + +- relaxed SNI hostname resolution + +## 0.6.5 - 2025-09-16 + +### Fixed + +- token is no longer required when authentication is done via certificates +- errors while running actions are now reported + +## 0.6.4 - 2025-09-03 + +### Added + +- improved diagnose support + +### Fixed + +- NPE during error reporting +- relaxed `Content-Type` checks while downloading the CLI + +## 0.6.3 - 2025-08-25 + +### Added + +- progress reporting while handling URIs + +### Changed + +- 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 + +- content-type is now enforced when downloading the CLI to accept only binary responses + +## 0.6.1 - 2025-08-11 + ### Added - support for skipping CLI signature verification diff --git a/README.md b/README.md index 3e5da521..2d50806d 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,27 @@ mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5 > in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 +### Mitmproxy returns 502 Bad Gateway to the client + +When running traffic through mitmproxy, you may encounter 502 Bad Gateway errors that mention HTTP/2 protocol error: * +*Received header value surrounded by whitespace**. +This happens because some upstream servers (including dev.coder.com) send back headers such as Content-Security-Policy +with leading or trailing spaces. +While browsers and many HTTP clients accept these headers, mitmproxy enforces the stricter HTTP/2 and HTTP/1.1 RFCs, +which forbid whitespace around header values. +As a result, mitmproxy rejects the response and surfaces a 502 to the client. + +The workaround is to disable HTTP/2 in mitmproxy and force HTTP/1.1 on both the client and upstream sides. This avoids +the strict header validation path and allows +mitmproxy to pass responses through unchanged. You can do this by starting mitmproxy with: + +```bash +mitmproxy --set http2=false --set upstream_http_version=HTTP/1.1 +``` + +This ensures coder toolbox http client ↔ mitmproxy ↔ server connections all run over HTTP/1.1, preventing the whitespace +error. + ## Debugging and Reporting issues Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH @@ -339,6 +360,16 @@ storage paths. The options can be configured from the plugin's main Workspaces p - `Header command` command that outputs additional HTTP headers. Each line of output must be in the format key=value. The environment variable CODER_URL will be available to the command process. +- `lastDeploymentURL` the last Coder deployment URL that Coder Toolbox successfully authenticated to. + +- `workspaceViewUrl` specifies the dashboard page full URL where users can view details about a workspace. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment workspace page. + This setting supports `$workspaceOwner` and `$workspaceName` as placeholders. + +- `workspaceCreateUrl` specifies the dashboard page full URL where users can create new workspaces. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page. + This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in. + ### TLS settings The following options control the secure communication behavior of the plugin with Coder deployment and its available diff --git a/gradle.properties b/gradle.properties index b31ebe62..d9ce8bf0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.6.1 +version=0.8.1 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28820b1a..253d2c1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,19 +4,19 @@ kotlin = "2.1.20" coroutines = "1.10.2" serialization = "1.8.1" okhttp = "4.12.0" -dependency-license-report = "2.9" -marketplace-client = "2.0.47" +dependency-license-report = "3.0.1" +marketplace-client = "2.0.50" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" ksp = "2.1.20-2.0.1" retrofit = "3.0.0" -changelog = "2.2.1" +changelog = "2.5.0" gettext = "0.7.0" -plugin-structure = "3.310" -mockk = "1.14.4" +plugin-structure = "3.320" +mockk = "1.14.7" detekt = "1.23.8" -bouncycastle = "1.81" +bouncycastle = "1.83" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index f8b3a17c..5cf160d0 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -12,18 +12,21 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action +import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.EnvironmentView import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook -import com.jetbrains.toolbox.api.remoteDev.DeleteEnvironmentConfirmationParams import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.jetbrains.toolbox.api.ui.actions.ActionDescription +import com.jetbrains.toolbox.api.ui.components.TextType import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -51,18 +54,18 @@ class CoderRemoteEnvironment( private var workspace: Workspace, private var agent: WorkspaceAgent, ) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook { - private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + private var environmentStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" private var isConnected: MutableStateFlow = MutableStateFlow(false) override val connectionRequest: MutableStateFlow = MutableStateFlow(false) override val state: MutableStateFlow = - MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) + MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) override val additionalEnvironmentInformation: MutableMap = mutableMapOf() - override val actionsList: MutableStateFlow> = MutableStateFlow(getAvailableActions()) + override val actionsList: MutableStateFlow> = MutableStateFlow(emptyList()) private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) @@ -73,77 +76,115 @@ class CoderRemoteEnvironment( context.logger.info("resuming SSH connection to $id — last session was still active.") startSshConnection() } + refreshAvailableActions() } fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) - private fun getAvailableActions(): List { - val actions = mutableListOf() - if (wsRawStatus.canStop()) { - actions.add(Action(context.i18n.ptrl("Open web terminal")) { - context.cs.launch { - context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { - context.ui.showErrorInfoPopup(it) - } + private fun refreshAvailableActions() { + val actions = mutableListOf() + context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") + if (environmentStatus.canStop()) { + actions.add(Action(context, "Open web terminal") { + context.logger.debug("Launching web terminal for $id...") + context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { + context.ui.showErrorInfoPopup(it) } - }) + } + ) } actions.add( - Action(context.i18n.ptrl("Open in dashboard")) { - context.cs.launch { - context.desktop.browse( - client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() - ) { - context.ui.showErrorInfoPopup(it) - } - } - }) - - actions.add(Action(context.i18n.ptrl("View template")) { - context.cs.launch { - context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + Action(context, "Open in dashboard") { + val urlTemplate = context.settingsStore.workspaceViewUrl + ?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + val url = urlTemplate + .replace("\$workspaceOwner", workspace.ownerName) + .replace("\$workspaceName", workspace.name) + context.logger.debug("Opening the dashboard for $id...") + context.desktop.browse( + url + ) { context.ui.showErrorInfoPopup(it) } } + ) + + actions.add(Action(context, "View template") { + context.logger.debug("Opening the template for $id...") + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) + } }) - if (wsRawStatus.canStart()) { + if (environmentStatus.canStart()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and start")) { - context.cs.launch { - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } + actions.add(Action(context, "Update and start") { + context.logger.debug("Updating and starting $id...") + val build = client.updateWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) }) } else { - actions.add(Action(context.i18n.ptrl("Start")) { - context.cs.launch { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - - } + actions.add(Action(context, "Start") { + context.logger.debug("Starting $id... ") + context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) + } + // cli takes 15 seconds to move the workspace in queueing/starting state + // while the user won't see anything happening in TBX after start is clicked + // During those 15 seconds we work around by forcing a `Queuing` state + updateStatus(WorkspaceAndAgentStatus.QUEUED) + // force refresh of the actions list (Start should no longer be available) + refreshAvailableActions() }) } } - if (wsRawStatus.canStop()) { + if (environmentStatus.canStop()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and restart")) { - context.cs.launch { - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) - } - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch { - tryStopSshConnection() - - val build = client.stopWorkspace(workspace) + actions.add(Action(context, "Update and restart") { + context.logger.debug("Updating and re-starting $id...") + val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } - }) + ) + } + actions.add(Action(context, "Stop") { + tryStopSshConnection() + context.logger.debug("Stoping $id...") + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + ) + } + actions.add(CoderDelimiter(context.i18n.pnotr(""))) + actions.add(Action(context, "Delete workspace", highlightInRed = true) { + context.cs.launch(CoroutineName("Delete Workspace Action")) { + var dialogText = + if (environmentStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." + else "This will remove all information from the workspace, including files, unsaved changes, history, and usage data." + dialogText += "\n\nType \"${workspace.name}\" below to confirm:" + + val confirmation = context.ui.showTextInputPopup( + if (environmentStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl( + "Delete workspace?" + ), + context.i18n.pnotr(dialogText), + context.i18n.ptrl("Workspace name"), + TextType.General, + context.i18n.ptrl("OK"), + context.i18n.ptrl("Cancel") + ) + if (confirmation != workspace.name) { + return@launch + } + context.logger.debug("Deleting $id...") + deleteWorkspace() + } + }) + + actionsList.update { + actions } - return actions } private suspend fun tryStopSshConnection() { @@ -169,7 +210,7 @@ class CoderRemoteEnvironment( pollJob = pollNetworkMetrics() } - private fun pollNetworkMetrics(): Job = context.cs.launch { + private fun pollNetworkMetrics(): Job = context.cs.launch(CoroutineName("Network Metrics Poller")) { context.logger.info("Starting the network metrics poll job for $id") while (isActive) { context.logger.debug("Searching SSH command's PID for workspace $id...") @@ -219,19 +260,26 @@ class CoderRemoteEnvironment( * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { + if (this.workspace.latestBuild == workspace.latestBuild) { + return + } this.workspace = workspace this.agent = agent - wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + // workspace&agent status can be different from "environment status" + // which is forced to queued state when a workspace is scheduled to start + updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property - actionsList.update { - getAvailableActions() - } - context.cs.launch { - state.update { - wsRawStatus.toRemoteEnvironmentState(context) - } + refreshAvailableActions() + } + + private fun updateStatus(status: WorkspaceAndAgentStatus) { + environmentStatus = status + state.update { + environmentStatus.toRemoteEnvironmentState(context) } + context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } /** @@ -261,54 +309,41 @@ class CoderRemoteEnvironment( * Returns true if the SSH connection was scheduled to start, false otherwise. */ fun startSshConnection(): Boolean { - if (wsRawStatus.ready() && !isConnected.value) { - context.cs.launch { - connectionRequest.update { - true - } + if (environmentStatus.ready() && !isConnected.value) { + connectionRequest.update { + true } return true } return false } - override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? { - return object : DeleteEnvironmentConfirmationParams { - override val cancelButtonText: String = "Cancel" - override val confirmButtonText: String = "Delete" - override val message: String = - if (wsRawStatus.canStop()) "Workspace will be closed and all the information will be lost, including all files, unsaved changes, historical info and usage data." - else "All the information in this workspace will be lost, including all files, unsaved changes, historical info and usage data." - override val title: String = if (wsRawStatus.canStop()) "Delete running workspace?" else "Delete workspace?" - } - } + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow(null) - override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { - context.cs.launch { - try { - client.removeWorkspace(workspace) - // mark the env as deleting otherwise we will have to - // wait for the poller to update the status in the next 5 seconds - state.update { - WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) - } + suspend fun deleteWorkspace() { + try { + client.removeWorkspace(workspace) + // mark the env as deleting otherwise we will have to + // wait for the poller to update the status in the next 5 seconds + state.update { + WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) + } - context.cs.launch { - withTimeout(5.minutes) { - var workspaceStillExists = true - while (context.cs.isActive && workspaceStillExists) { - if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { - workspaceStillExists = false - context.envPageManager.showPluginEnvironmentsPage() - } else { - delay(1.seconds) - } + context.cs.launch(CoroutineName("Workspace Deletion Poller")) { + withTimeout(5.minutes) { + var workspaceStillExists = true + while (context.cs.isActive && workspaceStillExists) { + if (environmentStatus == WorkspaceAndAgentStatus.DELETING || environmentStatus == WorkspaceAndAgentStatus.DELETED) { + workspaceStillExists = false + context.envPageManager.showPluginEnvironmentsPage() + } else { + delay(1.seconds) } } } - } catch (e: APIResponseException) { - context.ui.showErrorInfoPopup(e) } + } catch (e: APIResponseException) { + context.ui.showErrorInfoPopup(e) } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 2e5d5570..98cc1ab3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -2,17 +2,28 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.TOKEN +import com.coder.toolbox.util.URL +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.toQueryParameters +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.token +import com.coder.toolbox.util.url +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderCliSetupWizardPage +import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage +import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon @@ -21,9 +32,9 @@ import com.jetbrains.toolbox.api.core.util.LoadableState import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -35,7 +46,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI -import java.util.UUID +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -43,6 +55,7 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownM import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory private val POLL_INTERVAL = 5.seconds +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( @@ -54,31 +67,41 @@ class CoderRemoteProvider( private val settings = context.settingsStore.readOnly() - // Create our services from the Toolbox ones. private val triggerSshConfig = Channel(Channel.CONFLATED) - private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) + private val triggerProviderVisible = Channel(Channel.CONFLATED) private val dialogUi = DialogUi(context) // The REST client, if we are signed in private var client: CoderRestClient? = null + private var cli: CoderCLIManager? = null // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(false) + private val isHandlingUri: AtomicBoolean = AtomicBoolean(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) - private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - - override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") - override val environments: MutableStateFlow>> = MutableStateFlow( - LoadableState.Loading - ) - + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { + client?.let { restClient -> + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + } + } private val visibilityState = MutableStateFlow( ProviderVisibilityState( applicationVisible = false, providerVisible = false ) ) + private val linkHandler = CoderProtocolHandler(context) + + override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") + override val environments: MutableStateFlow>> = MutableStateFlow( + LoadableState.Loading + ) private val errorBuffer = mutableListOf() @@ -87,116 +110,124 @@ class CoderRemoteProvider( * workspace is added, reconfigure SSH using the provided cli (including the * first time). */ - private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch { - var lastPollTime = TimeSource.Monotonic.markNow() - while (isActive) { - try { - context.logger.debug("Fetching workspace agents from ${client.url}") - val resolvedEnvironments = client.workspaces().flatMap { ws -> - // Agents are not included in workspaces that are off - // so fetch them separately. - when (ws.latestBuild.status) { - WorkspaceStatus.RUNNING -> ws.latestBuild.resources - else -> emptyList() - }.ifEmpty { - client.resources(ws) - }.flatMap { resource -> - resource.agents?.distinctBy { - // There can be duplicates with coder_agent_instance. - // TODO: Can we just choose one or do they hold - // different information? - it.name - }?.map { agent -> - // If we have an environment already, update that. - val env = CoderRemoteEnvironment(context, client, cli, ws, agent) - lastEnvironments.firstOrNull { it == env }?.let { - it.update(ws, agent) - it - } ?: env - } ?: emptyList() - } - }.toSet() + private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = + context.cs.launch(CoroutineName("Workspace Poller")) { + var lastPollTime = TimeSource.Monotonic.markNow() + while (isActive) { + try { + context.logger.debug("Fetching workspace agents from ${client.url}") + val resolvedEnvironments = client.workspaces().flatMap { ws -> + // Agents are not included in workspaces that are off + // so fetch them separately. + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> emptyList() + }.ifEmpty { + client.resources(ws) + }.flatMap { resource -> + resource.agents?.distinctBy { + // There can be duplicates with coder_agent_instance. + // TODO: Can we just choose one or do they hold + // different information? + it.name + }?.map { agent -> + // If we have an environment already, update that. + val env = CoderRemoteEnvironment(context, client, cli, ws, agent) + lastEnvironments.firstOrNull { it == env }?.let { + it.update(ws, agent) + it + } ?: env + } ?: emptyList() + } + }.toSet() - // In case we logged out while running the query. - if (!isActive) { - return@launch - } + // In case we logged out while running the query. + if (!isActive) { + return@launch + } - // Reconfigure if environments changed. - if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { - context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") - cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) - } + // Reconfigure if environments changed. + if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { + context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") + cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + } - environments.update { - LoadableState.Value(resolvedEnvironments.toList()) - } - if (!isInitialized.value) { - context.logger.info("Environments for ${client.url} are now initialized") - isInitialized.update { - true + environments.update { + LoadableState.Value(resolvedEnvironments.toList()) + } + if (!isInitialized.value) { + context.logger.info("Environments for ${client.url} are now initialized") + isInitialized.update { + true + } + } + lastEnvironments.apply { + clear() + addAll(resolvedEnvironments.sortedBy { it.id }) } - } - lastEnvironments.apply { - clear() - addAll(resolvedEnvironments.sortedBy { it.id }) - } - if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) { - WorkspaceConnectionManager.allConnected().forEach { wsId -> - val env = lastEnvironments.firstOrNull() { it.id == wsId } - if (env != null && !env.isConnected()) { - context.logger.info("Establishing lost SSH connection for workspace with id $wsId") - if (!env.startSshConnection()) { - context.logger.info("Can't establish lost SSH connection for workspace with id $wsId") + if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) { + WorkspaceConnectionManager.allConnected().forEach { wsId -> + val env = lastEnvironments.firstOrNull() { it.id == wsId } + if (env != null && !env.isConnected()) { + context.logger.info("Establishing lost SSH connection for workspace with id $wsId") + if (!env.startSshConnection()) { + context.logger.info("Can't establish lost SSH connection for workspace with id $wsId") + } } } + WorkspaceConnectionManager.reset() } - WorkspaceConnectionManager.reset() - } - WorkspaceConnectionManager.collectStatuses(lastEnvironments) - } catch (_: CancellationException) { - context.logger.debug("${client.url} polling loop canceled") - break - } catch (ex: Exception) { - val elapsed = lastPollTime.elapsedNow() - if (elapsed > POLL_INTERVAL * 2) { - context.logger.info("wake-up from an OS sleep was detected") - } else { - context.logger.error(ex, "workspace polling error encountered") - if (ex is APIResponseException && ex.isTokenExpired) { - WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true - close() - context.envPageManager.showPluginEnvironmentsPage() - errorBuffer.add(ex) - break + WorkspaceConnectionManager.collectStatuses(lastEnvironments) + } catch (_: CancellationException) { + context.logger.debug("${client.url} polling loop canceled") + break + } catch (ex: Exception) { + val elapsed = lastPollTime.elapsedNow() + if (elapsed > POLL_INTERVAL * 2) { + context.logger.info("wake-up from an OS sleep was detected") + } else { + context.logger.error(ex, "workspace polling error encountered") + if (ex is APIResponseException && ex.isTokenExpired) { + WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + close() + context.envPageManager.showPluginEnvironmentsPage() + errorBuffer.add(ex) + break + } } } - } - select { - onTimeout(POLL_INTERVAL) { - context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout") - } - triggerSshConfig.onReceive { shouldTrigger -> - if (shouldTrigger) { - context.logger.trace("workspace poller waked up because it should reconfigure the ssh configurations") - cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + select { + onTimeout(POLL_INTERVAL) { + context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout") + } + triggerSshConfig.onReceive { shouldTrigger -> + if (shouldTrigger) { + context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations") + cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + } + } + triggerProviderVisible.onReceive { isCoderProviderVisible -> + if (isCoderProviderVisible) { + context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses") + } } } + lastPollTime = TimeSource.Monotonic.markNow() } - lastPollTime = TimeSource.Monotonic.markNow() } - } /** * Stop polling, clear the client and environments, then go back to the * first page. */ private fun logout() { + context.logger.info("Logging out ${client?.me?.username}...") WorkspaceConnectionManager.reset() close() + context.logger.info("User ${client?.me?.username} logged out successfully") } /** @@ -215,15 +246,17 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Create workspace")) { - context.cs.launch { - context.desktop.browse(client?.url?.withPath("/templates").toString()) { - context.ui.showErrorInfoPopup(it) - } + Action(context, "Create workspace") { + val url = context.settingsStore.workspaceCreateUrl ?: client?.url?.withPath("/templates").toString() + context.desktop.browse( + url + .replace("\$workspaceOwner", client?.me?.username ?: "") + ) { + context.ui.showErrorInfoPopup(it) } }, CoderDelimiter(context.i18n.pnotr("")), - Action(context.i18n.ptrl("Settings")) { + Action(context, "Settings") { context.ui.showUiPage(settingsPage) }, ) @@ -235,13 +268,25 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { - pollJob?.cancel() - client?.close() + softClose() + client = null + cli = null lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - client = null CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") + } + + private fun softClose() { + pollJob?.let { + it.cancel() + context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") + } + client?.let { + it.close() + context.logger.info("REST API client closed and resources released") + } } override val svgIcon: SvgIcon = @@ -293,6 +338,11 @@ class CoderRemoteProvider( visibilityState.update { visibility } + if (visibility.providerVisible) { + context.cs.launch(CoroutineName("Notify Plugin Visibility")) { + triggerProviderVisible.send(true) + } + } } /** @@ -300,51 +350,117 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - linkHandler.handle( - uri, shouldDoAutoSetup(), - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - true - } - }, - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - false + val params = uri.toQueryParameters() + if (params.isEmpty()) { + // probably a plugin installation scenario + context.logAndShowInfo("URI will not be handled", "No query parameters were provided") + return + } + isHandlingUri.set(true) + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + coderHeaderPage.isBusy.update { true } + context.logger.info("Handling $uri...") + val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return + val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return + if (sameUrl(newUrl, client?.url)) { + if (context.settingsStore.requiresTokenAuth) { + newToken?.let { + refreshSession(newUrl, it) } } - ) { restClient, cli -> - // stop polling and de-initialize resources - close() - isInitialized.update { - false + } else { + CoderCliSetupContext.apply { + url = newUrl + token = newToken + } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + connectSynchronously = true, + onConnect = ::onConnect + ).apply { + beforeShow() } - // start initialization with the new settings - this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - isInitialized.waitForTrue() } + // force the poll loop to run + triggerProviderVisible.send(true) + // wait for environments to be populated + isInitialized.waitForTrue() + + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + coderHeaderPage.isBusy.update { false } } catch (ex: Exception) { - context.logger.error(ex, "") val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { ex.reason } else ex.message } else ex.message - - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while handling Coder URI"), - context.i18n.pnotr(textError ?: ""), - context.i18n.ptrl("Dismiss") + context.logAndShowError( + "Error encountered while handling Coder URI", + textError ?: "" ) + context.envPageManager.showPluginEnvironmentsPage() } finally { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - false - } + coderHeaderPage.isBusy.update { false } + isHandlingUri.set(false) + firstRun = false + } + } + + private suspend fun resolveDeploymentUrl(params: Map): String? { + val deploymentURL = params.url() ?: askUrl() + if (deploymentURL.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"${URL}\" is missing from URI") + return null + } + val validationResult = deploymentURL.validateStrictWebUrl() + if (validationResult is Invalid) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") + return null + } + return deploymentURL + } + + private suspend fun resolveToken(params: Map): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null } + return token + } + + private fun sameUrl(first: URL, second: URL?): Boolean = first.toURI().normalize() == second?.toURI()?.normalize() + + private suspend fun refreshSession(url: URL, token: String): Pair { + context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") + softClose() + val newRestClient = CoderRestClient( + context, + url, + token, + PluginManager.pluginInfo.version, + ).apply { initializeSession() } + val newCli = CoderCLIManager(context, url).apply { + login(token) + } + this.client = newRestClient + this.cli = newCli + pollJob = poll(newRestClient, newCli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") + return newRestClient to newCli + } + + private suspend fun askUrl(): String? { + context.popupPluginMainPage() + return dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) } /** @@ -354,13 +470,25 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { + if (isHandlingUri.get()) { + return null + } // Show the setup page if we have not configured the client yet. if (client == null) { // When coming back to the application, initializeSession immediately. if (shouldDoAutoSetup()) { try { + CoderCliSetupContext.apply { + url = context.deploymentUrl + token = context.secrets.tokenFor(context.deploymentUrl) + } CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) + return CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = false, + onConnect = ::onConnect + ) } catch (ex: Exception) { errorBuffer.add(ex) } finally { @@ -386,19 +514,31 @@ class CoderRemoteProvider( * Auto-login only on first the firs run if there is a url & token configured or the auth * should be done via certificates. */ - private fun shouldDoAutoSetup(): Boolean = firstRun && (context.secrets.canAutoLogin || !settings.requireTokenAuth) + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) + + fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. - context.secrets.lastDeploymentURL = client.url.toString() - context.secrets.lastToken = client.token ?: "" - context.secrets.storeTokenFor(client.url, context.secrets.lastToken) + close() + context.settingsStore.updateLastUsedUrl(client.url) + if (context.settingsStore.requiresTokenAuth) { + context.secrets.storeTokenFor(client.url, client.token ?: "") + context.logger.info("Deployment URL and token were stored and will be available for automatic connection") + } else { + context.logger.info("Deployment URL was stored and will be available for automatic connection") + } this.client = client - pollJob?.cancel() + this.cli = cli environments.showLoadingMessage() - coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + } + context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) - context.envPageManager.showPluginEnvironmentsPage() + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") } private fun MutableStateFlow>>.showLoadingMessage() { @@ -406,6 +546,4 @@ class CoderRemoteProvider( LoadableState.Loading } } -} - -private class CoderDelimiter(override val label: LocalizableString) : ActionDelimiter \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 4291321e..ac3cbcc7 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -36,17 +36,15 @@ data class CoderToolboxContext( * * In order of preference: * - * 1. Last used URL. - * 2. URL in settings. - * 3. CODER_URL. - * 4. URL in global cli config. + * 1. Last used URL from the settings. + * 2. Last used URL from the secrets store. + * 3. Default URL */ val deploymentUrl: URL get() { - if (this.secrets.lastDeploymentURL.isNotBlank()) { - return this.secrets.lastDeploymentURL.toURL() - } - return this.settingsStore.defaultURL.toURL() + return settingsStore.lastDeploymentURL?.takeIf { it.isNotBlank() }?.toURL() + ?: secrets.lastDeploymentURL.takeIf { it.isNotBlank() }?.toURL() + ?: settingsStore.defaultURL.toURL() } suspend fun logAndShowError(title: String, error: String) { @@ -88,4 +86,9 @@ data class CoderToolboxContext( i18n.ptrl("OK") ) } + + fun popupPluginMainPage() { + this.ui.showWindow() + this.envPageManager.showPluginEnvironmentsPage(true) + } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 582a85b4..0fe6c25e 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -12,14 +12,15 @@ 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.ReloadableTlsContext 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 @@ -29,7 +30,6 @@ 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 @@ -37,7 +37,6 @@ 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. @@ -127,6 +126,7 @@ data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, val wildcardSsh: Boolean = false, + val buildReason: Boolean = false, ) /** @@ -148,13 +148,15 @@ 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, + ReloadableTlsContext(context.settingsStore.readOnly().tls) + ) val retrofit = Retrofit.Builder() .baseUrl(deploymentURL.toString()) @@ -305,6 +307,25 @@ class CoderCLIManager( ) } + /** + * Start a workspace. Throws if the command execution fails. + */ + fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String { + val args = mutableListOf( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + "$workspaceOwner/$workspaceName" + ) + + if (feats.buildReason) { + args.addAll(listOf("--reason", "jetbrains_connection")) + } + + return exec(*args.toTypedArray()) + } + /** * Configure SSH to use this binary. * @@ -316,6 +337,7 @@ class CoderCLIManager( ) { context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}") writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats)) + context.logger.info("Finished configuring SSH config") } /** @@ -354,24 +376,22 @@ class CoderCLIManager( // always use the correct URL. "--url", escape(deploymentURL.toString()), - if (!context.settingsStore.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!context.settingsStore.headerCommand.isNullOrBlank()) escapeSubcommand(context.settingsStore.headerCommand!!) else null, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { "--header-command" }, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { escapeSubcommand(it) }, "ssh", "--stdio", if (context.settingsStore.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, "--network-info-dir ${escape(context.settingsStore.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( - if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) escape(context.settingsStore.sshLogDirectory!!) else null, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { "--log-dir" }, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { escape(it) }, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val extraConfig = - if (!context.settingsStore.sshConfigOptions.isNullOrBlank()) { - "\n" + context.settingsStore.sshConfigOptions!!.prependIndent(" ") - } else { - "" - } + val extraConfig = context.settingsStore.sshConfigOptions + ?.takeIf { it.isNotBlank() } + ?.let { "\n" + it.prependIndent(" ") } + ?: "" val options = """ ConnectTimeout 0 StrictHostKeyChecking no @@ -571,7 +591,8 @@ class CoderCLIManager( Features( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), - version >= SemVer(2, 19, 0), + wildcardSsh = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), ) } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt index 03e3a4dc..2c2e87c6 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -25,6 +25,19 @@ import java.util.zip.GZIPInputStream import kotlin.io.path.name import kotlin.io.path.notExists +private val SUPPORTED_BIN_MIME_TYPES = listOf( + "application/octet-stream", + "application/exe", + "application/dos-exe", + "application/msdos-windows", + "application/x-exe", + "application/x-msdownload", + "application/x-winexe", + "application/x-msdos-program", + "application/x-msdos-executable", + "application/x-ms-dos-executable", + "application/vnd.microsoft.portable-executable" +) /** * Handles the download steps of Coder CLI */ @@ -51,6 +64,13 @@ class CoderDownloadService( return when (response.code()) { HTTP_OK -> { + val contentType = response.headers()["Content-Type"]?.lowercase() + if (contentType !in SUPPORTED_BIN_MIME_TYPES) { + throw ResponseException( + "Invalid content type '$contentType' when downloading CLI from $remoteBinaryURL. Expected application/octet-stream.", + response.code() + ) + } context.logger.info("Downloading binary to temporary $cliTempDst") response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt new file mode 100644 index 00000000..a526db05 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -0,0 +1,51 @@ +package com.coder.toolbox.sdk + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.util.ReloadableTlsContext +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient + +object CoderHttpClientBuilder { + fun build( + context: CoderToolboxContext, + interceptors: List, + tlsContext: ReloadableTlsContext + ): OkHttpClient { + val builder = OkHttpClient.Builder() + + context.proxySettings.getProxy()?.let { proxy -> + context.logger.info("proxy: $proxy") + builder.proxy(proxy) + } ?: context.proxySettings.getProxySelector()?.let { proxySelector -> + context.logger.info("proxy selector: $proxySelector") + builder.proxySelector(proxySelector) + } + + // 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(tlsContext.sslSocketFactory, tlsContext.trustManager) + .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) + .retryOnConnectionFailure(true) + + interceptors.forEach { interceptor -> + builder.addInterceptor(interceptor) + + } + return builder.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 9b2e7b3e..b44352d3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,29 +7,22 @@ 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.CertificateRefreshInterceptor +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.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace -import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceBuild 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.coder.toolbox.util.ReloadableTlsContext import com.squareup.moshi.Moshi -import okhttp3.Credentials import okhttp3.OkHttpClient import retrofit2.Response import retrofit2.Retrofit @@ -37,7 +30,6 @@ 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. @@ -50,13 +42,14 @@ open class CoderRestClient( val token: String?, private val pluginVersion: String = "development", ) { - private val settings = context.settingsStore.readOnly() + private lateinit var tlsContext: ReloadableTlsContext private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade lateinit var me: User lateinit var buildVersion: String + lateinit var appName: String init { setupSession() @@ -71,68 +64,27 @@ open class CoderRestClient( .add(UUIDConverter()) .build() - val socketFactory = coderSocketFactory(settings.tls) - val trustManagers = coderTrustManagers(settings.tls.caPath) - var builder = OkHttpClient.Builder() + tlsContext = ReloadableTlsContext(context.settingsStore.readOnly().tls) - 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.requiresTokenAuth) { + if (token.isNullOrBlank()) { + throw IllegalStateException("Token is required for $url deployment") + } + add(Interceptors.tokenAuth(token)) + } else if (context.settingsStore.requiresMTlsAuth && context.settingsStore.tls.certRefreshCommand?.isNotBlank() == true) { + add(CertificateRefreshInterceptor(context, tlsContext)) } + 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, + tlsContext + ) retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) @@ -153,6 +105,7 @@ open class CoderRestClient( suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version + appName = appearance().applicationName return me } @@ -160,7 +113,7 @@ open class CoderRestClient( * Retrieve the current user. * @throws [APIResponseException]. */ - suspend fun me(): User { + internal suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { throw APIResponseException( @@ -171,7 +124,28 @@ open class CoderRestClient( ) } - return userResponse.body()!! + return requireNotNull(userResponse.body()) { + "Successful response returned null body or user" + } + } + + /** + * Retrieves the visual dashboard configuration. + */ + internal suspend fun appearance(): Appearance { + val appearanceResponse = retroRestClient.appearance() + if (!appearanceResponse.isSuccessful) { + throw APIResponseException( + "initializeSession", + url, + appearanceResponse.code(), + appearanceResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(appearanceResponse.body()) { + "Successful response returned null body for visual dashboard configuration" + } } /** @@ -189,7 +163,9 @@ open class CoderRestClient( ) } - return workspacesResponse.body()!!.workspaces + return requireNotNull(workspacesResponse.body()?.workspaces) { + "Successful response returned null body or workspaces" + } } /** @@ -197,33 +173,19 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun workspace(workspaceID: UUID): Workspace { - val workspacesResponse = retroRestClient.workspace(workspaceID) - if (!workspacesResponse.isSuccessful) { + val workspaceResponse = retroRestClient.workspace(workspaceID) + if (!workspaceResponse.isSuccessful) { throw APIResponseException( "retrieve workspace", url, - workspacesResponse.code(), - workspacesResponse.parseErrorBody(moshi) + workspaceResponse.code(), + workspaceResponse.parseErrorBody(moshi) ) } - return workspacesResponse.body()!! - } - - /** - * Maps the available workspaces to the associated agents. - */ - suspend fun workspacesByAgents(): Set> { - // It is possible for there to be resources with duplicate names so we - // need to use a set. - return workspaces().flatMap { ws -> - when (ws.latestBuild.status) { - WorkspaceStatus.RUNNING -> ws.latestBuild.resources - else -> resources(ws) - }.filter { it.agents != null }.flatMap { it.agents!! }.map { - ws to it - } - }.toSet() + return requireNotNull(workspaceResponse.body()) { + "Successful response returned null body or workspace" + } } /** @@ -244,7 +206,10 @@ open class CoderRestClient( resourcesResponse.parseErrorBody(moshi) ) } - return resourcesResponse.body()!! + + return requireNotNull(resourcesResponse.body()) { + "Successful response returned null body or workspace resources" + } } suspend fun buildInfo(): BuildInfo { @@ -257,7 +222,10 @@ open class CoderRestClient( buildInfoResponse.parseErrorBody(moshi) ) } - return buildInfoResponse.body()!! + + return requireNotNull(buildInfoResponse.body()) { + "Successful response returned null body or build info" + } } /** @@ -273,12 +241,16 @@ open class CoderRestClient( templateResponse.parseErrorBody(moshi) ) } - return templateResponse.body()!! + + return requireNotNull(templateResponse.body()) { + "Successful response returned null body or template" + } } /** * @throws [APIResponseException]. */ + @Deprecated(message = "This operation needs to be delegated to the CLI") suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { val buildRequest = CreateWorkspaceBuildRequest( null, @@ -295,7 +267,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -311,7 +286,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -353,7 +331,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } fun close() { diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt new file mode 100644 index 00000000..55dae436 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.ReloadableTlsContext +import okhttp3.Interceptor +import okhttp3.Response +import org.zeroturnaround.exec.ProcessExecutor +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class CertificateRefreshInterceptor( + private val context: CoderToolboxContext, + private val tlsContext: ReloadableTlsContext +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + try { + return chain.proceed(request) + } catch (e: Exception) { + if ((e is SSLHandshakeException || e is SSLPeerUnverifiedException) && (e.message?.contains("certificate_expired") == true)) { + val command = context.settingsStore.tls.certRefreshCommand + if (command.isNullOrBlank()) { + throw IllegalStateException( + "Certificate expiration interceptor was set but the refresh command was removed in the meantime", + e + ) + } + + context.logger.info("SSL handshake exception encountered: certificates expired. Running certificate refresh command: $command") + try { + val result = ProcessExecutor() + .command(command.split(" ").toList()) + .exitValueNormal() + .readOutput(true) + .execute() + context.logger.info("`$command`: ${result.outputUTF8()}") + + if (result.exitValue == 0) { + context.logger.info("Certificate refresh command executed successfully. Reloading SSL certificates.") + tlsContext.reload() + // Retry the request + return chain.proceed(request) + } else { + context.logger.error("Certificate refresh command failed with exit code ${result.exitValue}") + } + } catch (ex: Exception) { + context.logger.error(ex, "Failed to execute certificate refresh command") + } + } + throw e + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt new file mode 100644 index 00000000..9c9f3ee6 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index adcaa6ef..5e7fc133 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk.v2 +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -23,6 +24,12 @@ interface CoderV2RestFacade { @GET("api/v2/users/me") suspend fun me(): Response + /** + * Returns the configuration of the visual dashboard. + */ + @GET("api/v2/appearance") + suspend fun appearance(): Response + /** * Retrieves all workspaces the authenticated user has access to. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt new file mode 100644 index 00000000..0c8d830b --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt @@ -0,0 +1,9 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Appearance( + @property:Json(name = "application_name") val applicationName: String +) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt index 2c5767e2..6b399871 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt @@ -10,20 +10,43 @@ import java.util.UUID */ @JsonClass(generateAdapter = true) data class WorkspaceBuild( - @Json(name = "template_version_id") val templateVersionID: UUID, - @Json(name = "resources") val resources: List, - @Json(name = "status") val status: WorkspaceStatus, + @property:Json(name = "id") val id: UUID, + @property:Json(name = "build_number") val buildNumber: Int, + @property:Json(name = "template_version_id") val templateVersionID: UUID, + @property:Json(name = "resources") val resources: List, + @property:Json(name = "status") val status: WorkspaceStatus, ) enum class WorkspaceStatus { - @Json(name = "pending") PENDING, - @Json(name = "starting") STARTING, - @Json(name = "running") RUNNING, - @Json(name = "stopping") STOPPING, - @Json(name = "stopped") STOPPED, - @Json(name = "failed") FAILED, - @Json(name = "canceling") CANCELING, - @Json(name = "canceled") CANCELED, - @Json(name = "deleting") DELETING, - @Json(name = "deleted") DELETED, -} + @Json(name = "pending") + PENDING, + + @Json(name = "starting") + STARTING, + + @Json(name = "running") + RUNNING, + + @Json(name = "stopping") + STOPPING, + + @Json(name = "stopped") + STOPPED, + + @Json(name = "failed") + FAILED, + + @Json(name = "canceling") + CANCELING, + + @Json(name = "canceled") + CANCELED, + + @Json(name = "deleting") + DELETING, + + @Json(name = "deleted") + DELETED; + + fun isNotStarted(): Boolean = this != STARTING && this != RUNNING +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 0000ea66..689f2793 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -8,11 +8,23 @@ import java.util.Locale.getDefault * Read-only interface for accessing Coder settings */ interface ReadOnlyCoderSettings { + + /** + * The last used deployment URL. + */ + val lastDeploymentURL: String? + /** * The default URL to show in the connection window. */ val defaultURL: String + /** + * Whether to display the application name instead of the URL + * in the main screen. Defaults to URL + */ + val useAppNameAsTitle: Boolean + /** * Used to download the Coder CLI which is necessary to proxy SSH * connections. The If-None-Match header will be set to the SHA1 of the CLI @@ -102,7 +114,12 @@ interface ReadOnlyCoderSettings { /** * Whether login should be done with a token */ - val requireTokenAuth: Boolean + val requiresTokenAuth: Boolean + + /** + * Whether the authentication is done with certificates. + */ + val requiresMTlsAuth: Boolean /** * Whether to add --disable-autostart to the proxy command. This works @@ -131,6 +148,17 @@ interface ReadOnlyCoderSettings { */ val sshConfigOptions: String? + /** + * A custom full URL to the dashboard page used for viewing details about a workspace. + * Supports `$workspaceOwner` and `$workspaceName` as placeholders. + */ + val workspaceViewUrl: String? + + /** + * A custom full URL to the dashboard page used for creating workspaces. + * Supports `$workspaceOwner` as placeholder. + */ + val workspaceCreateUrl: String? /** * The path where network information for SSH hosts are stored @@ -193,6 +221,12 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? + + /** + * Command to run when certificates expire and SSLHandshakeException + * is raised with `Received fatal alert: certificate_expired` as message + */ + val certRefreshCommand: String? } enum class SignatureFallbackStrategy { diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index a807b690..a5466b41 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -8,24 +8,11 @@ import java.net.URL * Provides Coder secrets backed by the secrets store service. */ class CoderSecretsStore(private val store: PluginSecretStore) { - private fun get(key: String): String = store[key] ?: "" - - private fun set(key: String, value: String) { - if (value.isBlank()) { - store.clear(key) - } else { - store[key] = value - } - } - - var lastDeploymentURL: String - get() = get("last-deployment-url") - set(value) = set("last-deployment-url", value) - var lastToken: String - get() = get("last-token") - set(value) = set("last-token", value) - val canAutoLogin: Boolean - get() = lastDeploymentURL.isNotBlank() && lastToken.isNotBlank() + @Deprecated( + message = "The URL is now stored the JSON backed settings store. Use CoderSettingsStore#lastDeploymentURL", + replaceWith = ReplaceWith("context.settingsStore.lastDeploymentURL") + ) + val lastDeploymentURL: String = store["last-deployment-url"] ?: "" fun tokenFor(url: URL): String? = store[url.host] diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index f770da85..ab8e54be 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -32,11 +32,14 @@ class CoderSettingsStore( override val certPath: String?, override val keyPath: String?, override val caPath: String?, - override val altHostname: String? + override val altHostname: String?, + override val certRefreshCommand: String? ) : ReadOnlyTLSSettings // Properties implementation + override val lastDeploymentURL: String? get() = store[LAST_USED_URL] override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" + override val useAppNameAsTitle: Boolean get() = store[APP_NAME_AS_TITLE]?.toBooleanStrictOrNull() ?: false override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] override val disableSignatureVerification: Boolean @@ -60,9 +63,11 @@ class CoderSettingsStore( certPath = store[TLS_CERT_PATH], keyPath = store[TLS_KEY_PATH], caPath = store[TLS_CA_PATH], - altHostname = store[TLS_ALTERNATE_HOSTNAME] + altHostname = store[TLS_ALTERNATE_HOSTNAME], + certRefreshCommand = store[TLS_CERT_REFRESH_COMMAND] ) - override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresMTlsAuth: Boolean get() = tls.certPath?.isNotBlank() == true && tls.keyPath?.isNotBlank() == true override val disableAutostart: Boolean get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC) override val isSshWildcardConfigEnabled: Boolean @@ -79,6 +84,11 @@ class CoderSettingsStore( .normalize() .toString() + override val workspaceViewUrl: String? + get() = store[WORKSPACE_VIEW_URL] + override val workspaceCreateUrl: String? + get() = store[WORKSPACE_CREATE_URL] + /** * Where the specified deployment should put its data. */ @@ -155,6 +165,14 @@ class CoderSettingsStore( fun readOnly(): ReadOnlyCoderSettings = this // Write operations + fun updateLastUsedUrl(url: URL) { + store[LAST_USED_URL] = url.toString() + } + + fun updateUseAppNameAsTitle(appNameAsTitle: Boolean) { + store[APP_NAME_AS_TITLE] = appNameAsTitle.toString() + } + fun updateBinarySource(source: String) { store[BINARY_SOURCE] = source } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 5f8f5af6..c199aecd 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -2,10 +2,12 @@ package com.coder.toolbox.store internal const val CODER_SSH_CONFIG_OPTIONS = "CODER_SSH_CONFIG_OPTIONS" -internal const val CODER_URL = "CODER_URL" +internal const val LAST_USED_URL = "lastDeploymentURL" internal const val DEFAULT_URL = "defaultURL" +internal const val APP_NAME_AS_TITLE = "useAppNameAsTitle" + internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" @@ -34,6 +36,8 @@ internal const val TLS_CA_PATH = "tlsCAPath" internal const val TLS_ALTERNATE_HOSTNAME = "tlsAlternateHostname" +internal const val TLS_CERT_REFRESH_COMMAND = "tlsCertRefreshCommand" + internal const val DISABLE_AUTOSTART = "disableAutostart" internal const val ENABLE_SSH_WILDCARD_CONFIG = "enableSshWildcardConfig" @@ -46,5 +50,8 @@ internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" internal const val NETWORK_INFO_DIR = "networkInfoDir" +internal const val WORKSPACE_VIEW_URL = "workspaceViewUrl" +internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" + internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index f0e84b92..ae6d13a1 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,22 +2,19 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager -import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.models.WorkspaceAndAgentStatus -import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.URI +import java.net.URL import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -25,13 +22,10 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" -private val noOpTextProgress: (String) -> Unit = { _ -> } @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, - private val dialogUi: DialogUi, - private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -43,86 +37,37 @@ open class CoderProtocolHandler( * connectable state. */ suspend fun handle( - uri: URI, - shouldWaitForAutoLogin: Boolean, - markAsBusy: () -> Unit, - unmarkAsBusy: () -> Unit, - reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + params: Map, + url: URL, + restClient: CoderRestClient, + cli: CoderCLIManager ) { - val params = uri.toQueryParameters() - if (params.isEmpty()) { - // probably a plugin installation scenario - context.logAndShowInfo("URI will not be handled", "No query parameters were provided") - return - } - // this switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() - markAsBusy() - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() - } - - context.logger.info("Handling $uri...") - val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - val restClient = buildRestClient(deploymentURL, token) ?: return - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return - - val cli = configureCli(deploymentURL, restClient) - - var agent: WorkspaceAgent - try { - reInitialize(restClient, cli) - context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + val workspace = restClient.workspaces().matchName(workspaceName, url) + if (workspace != null) { + if (!prepareWorkspace(workspace, restClient, cli, url)) return // we resolve the agent after the workspace is started otherwise we can get misleading // errors like: no agent available while workspace is starting or stopping // we also need to retrieve the workspace again to have the latest resources (ex: agent) // attached to the workspace. - agent = resolveAgent( + val agent: WorkspaceAgent = resolveAgent( params, restClient.workspace(workspace.id) ) ?: return if (!ensureAgentIsReady(workspace, agent)) return - } finally { - unmarkAsBusy() - } - delay(2.seconds) - val environmentId = "${workspace.name}.${agent.name}" - context.showEnvironmentPage(environmentId) + delay(2.seconds) + val environmentId = "${workspace.name}.${agent.name}" + context.showEnvironmentPage(environmentId) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - val projectFolder = params.projectFolder() - - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - launchIde(environmentId, productCode, buildNumber, projectFolder) - } - } + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() - private suspend fun resolveDeploymentUrl(params: Map): String? { - val deploymentURL = params.url() ?: askUrl() - if (deploymentURL.isNullOrBlank()) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") - return null - } - val validationResult = deploymentURL.validateStrictWebUrl() - if (validationResult is Invalid) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") - return null - } - return deploymentURL - } + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } - private suspend fun resolveToken(params: Map): String? { - val token = params.token() - if (token.isNullOrBlank()) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") - return null } - return token } private suspend fun resolveWorkspaceName(params: Map): String? { @@ -134,30 +79,7 @@ open class CoderProtocolHandler( return workspace } - private suspend fun buildRestClient(deploymentURL: String, token: String?): CoderRestClient? { - try { - return authenticate(deploymentURL, token) - } catch (ex: Exception) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) - return null - } - } - - /** - * Returns an authenticated Coder CLI. - */ - private suspend fun authenticate(deploymentURL: String, token: String?): CoderRestClient { - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.initializeSession() - return client - } - - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + private suspend fun List.matchName(workspaceName: String, deploymentURL: URL): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { context.logAndShowError( @@ -172,15 +94,15 @@ open class CoderProtocolHandler( private suspend fun prepareWorkspace( workspace: Workspace, restClient: CoderRestClient, - workspaceName: String, - deploymentURL: String + cli: CoderCLIManager, + url: URL ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be ready on time" + "${workspace.name} from $url could not be ready on time" ) return false } @@ -190,7 +112,7 @@ open class CoderProtocolHandler( if (settings.disableAutostart) { context.logAndShowWarning( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL is not running and autostart is disabled" + "${workspace.name} from $url is not running and autostart is disabled" ) return false } @@ -199,12 +121,12 @@ open class CoderProtocolHandler( if (workspace.outdated) { restClient.updateWorkspace(workspace) } else { - restClient.startWorkspace(workspace) + cli.startWorkspace(workspace.ownerName, workspace.name) } } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started", + "${workspace.name} from $url could not be started", e ) return false @@ -213,7 +135,7 @@ open class CoderProtocolHandler( if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started on time", + "${workspace.name} from $url could not be started on time", ) return false } @@ -222,7 +144,7 @@ open class CoderProtocolHandler( WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "Unable to connect to $workspaceName from $deploymentURL" + "Unable to connect to ${workspace.name} from $url" ) return false } @@ -257,7 +179,10 @@ open class CoderProtocolHandler( parameters: Map, workspace: Workspace, ): WorkspaceAgent? { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + val agents = workspace.latestBuild.resources + .mapNotNull { it.agents } + .flatten() + if (agents.isEmpty()) { context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") return null @@ -306,35 +231,13 @@ open class CoderProtocolHandler( return true } - private suspend fun configureCli( - deploymentURL: String, - restClient: CoderRestClient - ): CoderCLIManager { - val cli = ensureCLI( - context, - deploymentURL.toURL(), - restClient.buildInfo().version, - noOpTextProgress - ) - - // We only need to log in if we are using token-based auth. - if (restClient.token != null) { - context.logger.info("Authenticating Coder CLI...") - cli.login(restClient.token) - } - - context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.workspacesByAgents()) - return cli - } - private fun launchIde( environmentId: String, productCode: String, buildNumber: String, projectFolder: String? ) { - context.cs.launch { + context.cs.launch(CoroutineName("Launch Remote IDE")) { val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") installJBClient(selectedIde, environmentId).join() @@ -393,19 +296,17 @@ open class CoderProtocolHandler( val buildNumberIsNotAvailable = availableVersions.firstOrNull { it.contains(buildNumber) } == null if (buildNumberIsNotAvailable) { val selectedIde = availableVersions.maxOf { it } - context.logAndShowInfo( - "$productCode-$buildNumber not available", - "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" - ) + context.logger.info("$productCode-$buildNumber is not available, we've selected the latest $selectedIde") return selectedIde } return "$productCode-$buildNumber" } - private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { - context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") - context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) - } + private fun installJBClient(selectedIde: String, environmentId: String): Job = + context.cs.launch(CoroutineName("JBClient Installer")) { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { context.logger.info("Launching $selectedIde on $environmentId") @@ -445,25 +346,9 @@ open class CoderProtocolHandler( return false } } - - private suspend fun askUrl(): String? { - context.popupPluginMainPage() - return dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) - } -} - - -private fun CoderToolboxContext.popupPluginMainPage() { - this.ui.showWindow() - this.envPageManager.showPluginEnvironmentsPage(true) } private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { this.ui.showWindow() this.envPageManager.showEnvironmentPage(envId, false) -} - -class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index dac816e6..101370d2 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -4,8 +4,10 @@ import com.coder.toolbox.settings.ReadOnlyTLSSettings import okhttp3.internal.tls.OkHostnameVerifier import java.io.File import java.io.FileInputStream +import java.net.IDN import java.net.InetAddress import java.net.Socket +import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.KeyStore import java.security.cert.CertificateException @@ -18,11 +20,12 @@ import java.util.Locale import javax.net.ssl.HostnameVerifier import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SNIHostName +import javax.net.ssl.SNIServerName import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.StandardConstants import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -81,13 +84,39 @@ fun sslContextFromPEMs( return sslContext } +/** + * Netflix TLS Workaround — SNI & Hostname Validation + * + * Context: + * - The Netflix servers we connect to rely on the SNI in the ClientHello + * beyond just the typical use case of serving multiple hostnames from a + * single IP. The alternate hostname for the SNI can contain underscores + * (non-compliant for hostnames). + * - The server always presents the same certificate, regardless of the SNI + * - The certificate’s SAN entries do not match the server’s DNS name, and in + * - Because of this mismatch, the TLS handshake fails unless we apply two + * client-side workarounds: + * + * 1. SNI manipulation — we rewrite the SNI in the ClientHello via a custom + * SSLSocketFactory. Even though the server’s cert does not vary by SNI, + * connections fail if this rewrite is removed. The server’s TLS stack + * appears to depend on the SNI being set in a particular way. + * + * 2. Hostname validation override — we relax certificate checks by allowing + * an “alternate hostname” to be matched against the cert SANs. This avoids + * rejections when the SAN does not align with the requested DNS name. + * + * See [this issue](https://github.com/coder/jetbrains-coder/issues/578) for more details. + */ fun coderSocketFactory(settings: ReadOnlyTLSSettings): SSLSocketFactory { val sslContext = sslContextFromPEMs(settings.certPath, settings.keyPath, settings.caPath) - if (settings.altHostname.isNullOrBlank()) { + + val altHostname = settings.altHostname + if (altHostname.isNullOrBlank()) { return sslContext.socketFactory } - return AlternateNameSSLSocketFactory(sslContext.socketFactory, settings.altHostname) + return AlternateNameSSLSocketFactory(sslContext.socketFactory, altHostname) } fun coderTrustManagers(tlsCAPath: String?): Array { @@ -111,7 +140,7 @@ fun coderTrustManagers(tlsCAPath: String?): Array { return trustManagerFactory.trustManagers.map { MergedSystemTrustManger(it as X509TrustManager) }.toTypedArray() } -class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String?) : +class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : SSLSocketFactory() { override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites @@ -176,12 +205,19 @@ class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, priv private fun customizeSocket(socket: SSLSocket) { val params = socket.sslParameters - params.serverNames = listOf(SNIHostName(alternateName)) + + params.serverNames = listOf(RelaxedSNIHostname(alternateName)) socket.sslParameters = params } } +private class RelaxedSNIHostname(hostname: String) : SNIServerName( + StandardConstants.SNI_HOST_NAME, + IDN.toASCII(hostname, 0).toByteArray(StandardCharsets.UTF_8) +) + class CoderHostnameVerifier(private val alternateName: String?) : HostnameVerifier { + override fun verify( host: String, session: SSLSession, @@ -244,3 +280,77 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) : override fun getAcceptedIssuers(): Array = otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers } + +class ReloadableX509TrustManager( + private val caPath: String?, +) : X509TrustManager { + @Volatile + private var delegate: X509TrustManager = loadTrustManager() + + private fun loadTrustManager(): X509TrustManager { + val trustManagers = coderTrustManagers(caPath) + return trustManagers.first { it is X509TrustManager } as X509TrustManager + } + + fun reload() { + delegate = loadTrustManager() + } + + override fun checkClientTrusted(chain: Array?, authType: String?) { + delegate.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + delegate.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array { + return delegate.acceptedIssuers + } +} + +class ReloadableSSLSocketFactory( + private val settings: ReadOnlyTLSSettings, +) : SSLSocketFactory() { + @Volatile + private var delegate: SSLSocketFactory = loadSocketFactory() + + private fun loadSocketFactory(): SSLSocketFactory { + return coderSocketFactory(settings) + } + + fun reload() { + delegate = loadSocketFactory() + } + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + override fun createSocket(): Socket = delegate.createSocket() + + override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket = + delegate.createSocket(s, host, port, autoClose) + + override fun createSocket(host: String?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket = + delegate.createSocket(host, port, localHost, localPort) + + override fun createSocket(host: InetAddress?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket = + delegate.createSocket(address, port, localAddress, localPort) +} + +class ReloadableTlsContext( + settings: ReadOnlyTLSSettings +) { + val sslSocketFactory = ReloadableSSLSocketFactory(settings) + val trustManager = ReloadableX509TrustManager(settings.caPath) + + fun reload() { + sslSocketFactory.reload() + trustManager.reload() + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 51152043..2c740241 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -3,43 +3,44 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.sdk.ex.APIResponseException -import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.util.UUID class CoderCliSetupWizardPage( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, - private val visibilityState: MutableStateFlow, + visibilityState: StateFlow, initialAutoSetup: Boolean = false, + jumpToMainPageOnError: Boolean = false, + connectSynchronously: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) - private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { + private val settingsAction = Action(context, "Settings") { context.ui.showUiPage(settingsPage) - }) + } - private val deploymentUrlStep = DeploymentUrlStep(context, this::notify) + private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState) private val tokenStep = TokenStep(context) private val connectStep = ConnectStep( context, - shouldAutoSetup, - this::notify, - this::displaySteps, + shouldAutoLogin = shouldAutoSetup, + jumpToMainPageOnError = jumpToMainPageOnError, + connectSynchronously = connectSynchronously, + visibilityState, + refreshWizard = this::displaySteps, onConnect ) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) /** * Fields for this page, displayed in order. @@ -47,23 +48,10 @@ class CoderCliSetupWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) - private val errorBuffer = mutableListOf() - - init { - if (shouldAutoSetup.value) { - CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL() - CoderCliSetupContext.token = context.secrets.lastToken - } - } override fun beforeShow() { displaySteps() - if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { - errorBuffer.forEach { - showError(it) - } - errorBuffer.clear() - } + errorReporter.flush() } private fun displaySteps() { @@ -74,7 +62,7 @@ class CoderCliSetupWizardPage( } actionButtons.update { listOf( - Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + Action(context, "Next", closesPage = false, actionBlock = { if (deploymentUrlStep.onNext()) { displaySteps() } @@ -91,13 +79,13 @@ class CoderCliSetupWizardPage( } actionButtons.update { listOf( - Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { + Action(context, "Connect", closesPage = false, actionBlock = { if (tokenStep.onNext()) { displaySteps() } }), settingsAction, - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + Action(context, "Back", closesPage = false, actionBlock = { tokenStep.onBack() displaySteps() }) @@ -113,7 +101,7 @@ class CoderCliSetupWizardPage( actionButtons.update { listOf( settingsAction, - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + Action(context, "Back", closesPage = false, actionBlock = { connectStep.onBack() shouldAutoSetup.update { false @@ -130,30 +118,5 @@ class CoderCliSetupWizardPage( /** * Show an error as a popup on this page. */ - fun notify(logPrefix: String, ex: Throwable) { - context.logger.error(ex, logPrefix) - if (!visibilityState.value.applicationVisible) { - context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") - errorBuffer.add(ex) - return - } - showError(ex) - } - - private fun showError(ex: Throwable) { - val textError = if (ex is APIResponseException) { - if (!ex.reason.isNullOrBlank()) { - ex.reason - } else ex.message - } else ex.message - - context.cs.launch { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.ptrl("Error encountered while setting up Coder"), - context.i18n.pnotr(textError ?: ""), - context.i18n.ptrl("Dismiss") - ) - } - } + fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex) } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 363d6189..29a1e15d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -1,13 +1,17 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.ex.APIResponseException import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Base page that handles the icon, displaying error notifications, and @@ -30,6 +34,8 @@ abstract class CoderPage( } } + override val isBusy: MutableStateFlow = MutableStateFlow(false) + /** * Return the icon, if showing one. * @@ -43,27 +49,37 @@ abstract class CoderPage( } else { SvgIcon(byteArrayOf(), type = IconType.Masked) } - - override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) - - companion object { - fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) - } } /** * An action that simply runs the provided callback. */ class Action( - description: LocalizableString, + private val context: CoderToolboxContext, + private val description: String, closesPage: Boolean = false, + highlightInRed: Boolean = false, enabled: () -> Boolean = { true }, - private val actionBlock: () -> Unit, + private val actionBlock: suspend () -> Unit, ) : RunnableActionDescription { - override val label: LocalizableString = description + override val label: LocalizableString = context.i18n.ptrl(description) override val shouldClosePage: Boolean = closesPage override val isEnabled: Boolean = enabled() + override val isDangerous: Boolean = highlightInRed override fun run() { - actionBlock() + context.cs.launch(CoroutineName("$description Action")) { + try { + actionBlock() + } catch (ex: Exception) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + context.logAndShowError("Error while running `$description`", textError ?: "", ex) + } + } } } + +class CoderDelimiter(override val label: LocalizableString) : ActionDelimiter \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index e9376002..b74b2d8c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -12,6 +12,7 @@ import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException @@ -27,7 +28,11 @@ import kotlinx.coroutines.launch * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, * I have not been able to test this page. */ -class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConfig: Channel) : +class CoderSettingsPage( + private val context: CoderToolboxContext, + triggerSshConfig: Channel, + private val onSettingsClosed: () -> Unit +) : CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() @@ -40,6 +45,8 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + private val useAppNameField = + CheckboxField(settings.useAppNameAsTitle, context.i18n.ptrl("Use app name as main page title instead of URL")) private val disableSignatureVerificationField = CheckboxField( settings.disableSignatureVerification, @@ -94,6 +101,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf listOf( binarySourceField, enableDownloadsField, + useAppNameField, binaryDirectoryField, enableBinaryDirectoryFallbackField, disableSignatureVerificationField, @@ -115,11 +123,12 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override val actionButtons: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Save"), closesPage = true) { + Action(context, "Save", closesPage = true) { context.settingsStore.updateBinarySource(binarySourceField.contentState.value) context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateUseAppNameAsTitle(useAppNameField.checkedState.value) context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) @@ -134,7 +143,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value) if (enableSshWildCardConfig.checkedState.value != oldIsSshWildcardConfigEnabled) { - context.cs.launch { + context.cs.launch(CoroutineName("SSH Wildcard Setting")) { try { triggerSshConfig.send(true) context.logger.info("Wildcard settings have been modified from $oldIsSshWildcardConfigEnabled to ${!oldIsSshWildcardConfigEnabled}, ssh config is going to be regenerated...") @@ -163,6 +172,9 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf enableDownloadsField.checkedState.update { settings.enableDownloads } + useAppNameField.checkedState.update { + settings.useAppNameAsTitle + } signatureFallbackStrategyField.checkedState.update { settings.fallbackOnCoderForSignatures.isAllowed() } @@ -211,7 +223,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf settings.networkInfoDir } - visibilityUpdateJob = context.cs.launch { + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { disableSignatureVerificationField.checkedState.collect { state -> signatureFallbackStrategyField.visibility.update { // the fallback checkbox should not be visible @@ -224,5 +236,6 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override fun afterHide() { visibilityUpdateJob.cancel() + onSettingsClosed() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 7ea93e4a..3c1c8ef9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -7,15 +7,19 @@ import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield -import java.util.concurrent.CancellationException private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -25,17 +29,17 @@ private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, - private val notify: (String, Throwable) -> Unit, + private val jumpToMainPageOnError: Boolean, + private val connectSynchronously: Boolean, + visibilityState: StateFlow, private val refreshWizard: () -> Unit, - private val onConnect: suspend ( - client: CoderRestClient, - cli: CoderCLIManager, - ) -> Unit, + private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, ) : WizardStep { private var signInJob: Job? = null private val statusField = LabelField(context.i18n.pnotr("")) private val errorField = ValidationErrorField(context.i18n.pnotr("")) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) override val panel: RowGroup = RowGroup( RowGroup.RowField(statusField), @@ -43,18 +47,19 @@ class ConnectStep( ) override fun onVisible() { + errorReporter.flush() errorField.textState.update { context.i18n.pnotr("") } - if (context.settingsStore.requireTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { errorField.textState.update { context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") } return } - statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url!!.host}...") } + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url?.host ?: "unknown host"}...") } connect() } @@ -62,28 +67,37 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - if (!CoderCliSetupContext.hasUrl()) { + val url = CoderCliSetupContext.url + if (url == null) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } + + // Capture the host name early for error reporting + val hostName = url.host + + // Cancel previous job regardless of the new mode signInJob?.cancel() - signInJob = context.cs.launch { + + // 1. Extract the logic into a reusable suspend lambda + val connectionLogic: suspend CoroutineScope.() -> Unit = { try { + context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, - CoderCliSetupContext.url!!, - if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, + url, + if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action yield() client.initializeSession() - statusField.textState.update { (context.i18n.ptrl("Checking Coder CLI...")) } + logAndReportProgress("Checking Coder CLI...") val cli = ensureCLI( context, client.url, client.buildVersion @@ -91,30 +105,69 @@ class ConnectStep( statusField.textState.update { (context.i18n.pnotr(progress)) } } // We only need to log in if we are using token-based auth. - if (context.settingsStore.requireTokenAuth) { - statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) } + if (context.settingsStore.requiresTokenAuth) { + logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() cli.login(client.token!!) } - statusField.textState.update { (context.i18n.ptrl("Successfully configured ${CoderCliSetupContext.url!!.host}...")) } + logAndReportProgress("Successfully configured ${hostName}...") // allows interleaving with the back/cancel action yield() + context.logger.info("Connection setup done, initializing the workspace poller...") + onConnect(client, cli) + CoderCliSetupContext.reset() CoderCliSetupWizardState.goToFirstStep() - onConnect(client, cli) + context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex) - onBack() + errorReporter.report("Connection to $hostName was configured", ex) + handleNavigation() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex) - onBack() + errorReporter.report("Failed to configure $hostName", ex) + handleNavigation() refreshWizard() } } + + // 2. Choose the execution strategy based on the flag + if (connectSynchronously) { + // Blocks the current thread until connectionLogic completes + runBlocking(CoroutineName("Synchronous Http and CLI Setup")) { + connectionLogic() + } + } else { + // Runs asynchronously using the context's scope + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + } + } + + private fun logAndReportProgress(msg: String) { + context.logger.info(msg) + statusField.textState.update { context.i18n.pnotr(msg) } + } + + /** + * Handle navigation logic for both errors and back button + */ + private fun handleNavigation() { + if (shouldAutoLogin.value) { + CoderCliSetupContext.reset() + if (jumpToMainPageOnError) { + context.popupPluginMainPage() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } else { + if (context.settingsStore.requiresTokenAuth) { + CoderCliSetupWizardState.goToPreviousStep() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } } override fun onNext(): Boolean { @@ -123,18 +176,10 @@ class ConnectStep( override fun onBack() { try { + context.logger.info("Back button was pressed, cancelling in-progress connection setup...") signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { - if (shouldAutoLogin.value) { - CoderCliSetupContext.reset() - CoderCliSetupWizardState.goToFirstStep() - } else { - if (context.settingsStore.requireTokenAuth) { - CoderCliSetupWizardState.goToPreviousStep() - } else { - CoderCliSetupWizardState.goToFirstStep() - } - } + handleNavigation() } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 34b027c4..b4a60668 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -6,6 +6,7 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.LabelStyleType @@ -13,6 +14,7 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import java.net.MalformedURLException import java.net.URL @@ -25,9 +27,11 @@ import java.net.URL */ class DeploymentUrlStep( private val context: CoderToolboxContext, - private val notify: (String, Throwable) -> Unit + visibilityState: StateFlow, ) : WizardStep { + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) + private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal) @@ -59,13 +63,14 @@ class DeploymentUrlStep( errorField.textState.update { context.i18n.pnotr("") } - urlField.textState.update { - context.secrets.lastDeploymentURL + urlField.contentState.update { + context.deploymentUrl.toString() } signatureFallbackStrategyField.checkedState.update { context.settingsStore.fallbackOnCoderForSignatures.isAllowed() } + errorReporter.flush() } override fun onNext(): Boolean { @@ -78,10 +83,10 @@ class DeploymentUrlStep( try { CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { - notify("URL is invalid", e) + errorReporter.report("URL is invalid", e) return false } - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { CoderCliSetupWizardState.goToNextStep() } else { CoderCliSetupWizardState.goToLastStep() diff --git a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt new file mode 100644 index 00000000..88ace652 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt @@ -0,0 +1,73 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.ex.APIResponseException +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +sealed class ErrorReporter { + + /** + * Logs and show errors as popups. + */ + abstract fun report(message: String, ex: Throwable) + + /** + * Processes any buffered errors when the application becomes visible. + */ + abstract fun flush() + + companion object { + fun create( + context: CoderToolboxContext, + visibilityState: StateFlow, + callerClass: Class<*> + ): ErrorReporter = ErrorReporterImpl(context, visibilityState, callerClass) + } +} + +private class ErrorReporterImpl( + private val context: CoderToolboxContext, + private val visibilityState: StateFlow, + private val callerClass: Class<*> +) : ErrorReporter() { + private val errorBuffer = mutableListOf() + + override fun report(message: String, ex: Throwable) { + context.logger.error(ex, "[${callerClass.simpleName}] $message") + if (!visibilityState.value.applicationVisible) { + context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") + errorBuffer.add(ex) + return + } + showError(ex) + } + + private fun showError(ex: Throwable) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message ?: ex.toString() + context.cs.launch { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while setting up Coder"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } + } + + + override fun flush() { + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } + } +} \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 8aabe3fc..16b6ed5a 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -179,4 +179,16 @@ msgid "Headers" msgstr "" msgid "Body" +msgstr "" + +msgid "Delete workspace" +msgstr "" + +msgid "Delete running workspace?" +msgstr "" + +msgid "Workspace name" +msgstr "" + +msgid "Use app name as main page title instead of URL" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 4ef12356..74caf65c 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -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 @@ -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 @@ -87,8 +90,17 @@ internal class CoderCLIManagerTest { mockk(relaxed = true) ), mockk(), - mockk() - ) + 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() { @@ -137,6 +149,7 @@ internal class CoderCLIManagerTest { } val body = response.toByteArray() + exchange.responseHeaders["Content-Type"] = "application/octet-stream" exchange.sendResponseHeaders(code, if (code == HttpURLConnection.HTTP_OK) body.size.toLong() else -1) exchange.responseBody.write(body) exchange.close() @@ -197,11 +210,11 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( context.copy( settingsStore = CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), - ), - Environment(), - context.logger + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), + ), + Environment(), + context.logger ) ), url @@ -307,11 +320,11 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( context.copy( settingsStore = CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), - ), - Environment(), - context.logger + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), + ), + Environment(), + context.logger ) ), URL("https://foo") @@ -329,12 +342,12 @@ internal class CoderCLIManagerTest { val ccm = CoderCLIManager( context.copy( settingsStore = CoderSettingsStore( - pluginTestSettingsStore( - FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", - DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), - ), - Environment(), - context.logger + pluginTestSettingsStore( + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), + ), + Environment(), + context.logger ) ), url @@ -546,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. @@ -964,8 +976,24 @@ internal class CoderCLIManagerTest { val tests = listOf( Pair("2.5.0", Features(true)), - Pair("2.13.0", Features(true, true)), - Pair("4.9.0", Features(true, true, true)), + Pair("2.13.0", Features(disableAutostart = true, reportWorkspaceUsage = true)), + Pair( + "2.25.0", + Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), + Pair( + "4.9.0", Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), Pair("2.4.9", Features(false)), Pair("1.0.1", Features(false)), ) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt index 6d23c57b..bd8762d8 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt @@ -12,6 +12,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import java.util.UUID +import kotlin.random.Random class DataGen { companion object { @@ -20,19 +21,19 @@ class DataGen { agentId: String, ): WorkspaceResource = WorkspaceResource( agents = - listOf( - WorkspaceAgent( - id = UUID.fromString(agentId), - status = WorkspaceAgentStatus.CONNECTED, - name = agentName, - architecture = Arch.from("amd64"), - operatingSystem = OS.from("linux"), - directory = null, - expandedDirectory = null, - lifecycleState = WorkspaceAgentLifecycleState.READY, - loginBeforeReady = false, + listOf( + WorkspaceAgent( + id = UUID.fromString(agentId), + status = WorkspaceAgentStatus.CONNECTED, + name = agentName, + architecture = Arch.from("amd64"), + operatingSystem = OS.from("linux"), + directory = null, + expandedDirectory = null, + lifecycleState = WorkspaceAgentLifecycleState.READY, + loginBeforeReady = false, + ), ), - ), ) fun workspace( @@ -48,9 +49,9 @@ class DataGen { templateDisplayName = "template-display-name", templateIcon = "template-icon", latestBuild = - build( - resources = agents.map { resource(it.key, it.value) }, - ), + build( + resources = agents.map { resource(it.key, it.value) }, + ), outdated = false, name = name, ownerName = "owner", @@ -61,6 +62,8 @@ class DataGen { templateVersionID: UUID = UUID.randomUUID(), resources: List = emptyList(), ): WorkspaceBuild = WorkspaceBuild( + id = UUID.randomUUID(), + buildNumber = Random.nextInt(), templateVersionID = templateVersionID, resources = resources, status = WorkspaceStatus.RUNNING, diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index 50334876..9d38c4fe 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -261,19 +261,19 @@ internal class CoderSettingsTest { @Test fun testRequireTokenAuth() { var settings = CoderSettingsStore(pluginTestSettingsStore(), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_CERT_PATH to "cert path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_KEY_PATH to "key path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore( pluginTestSettingsStore(TLS_CERT_PATH to "cert path", TLS_KEY_PATH to "key path"), Environment(), logger ) - assertEquals(false, settings.readOnly().requireTokenAuth) + assertEquals(false, settings.readOnly().requiresTokenAuth) } @Test diff --git a/src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt b/src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt new file mode 100644 index 00000000..1b5460f0 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/AlternateNameSSLSocketFactoryTest.kt @@ -0,0 +1,237 @@ +package com.coder.toolbox.util + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.net.InetAddress +import java.net.Socket +import javax.net.ssl.SSLParameters +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + + +class AlternateNameSSLSocketFactoryTest { + + @Test + fun `createSocket with no parameters should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket() + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with host and port should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket("original.com", 443) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket("original.com", 443) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with host port and local address should customize socket`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val localHost = mockk() + + every { mockFactory.createSocket("original.com", 443, localHost, 8080) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket("original.com", 443, localHost, 8080) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with InetAddress should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val address = mockk() + + every { mockFactory.createSocket(address, 443) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(address, 443) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with InetAddress and local address should customize socket`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val address = mockk() + val localAddress = mockk() + + every { mockFactory.createSocket(address, 443, localAddress, 8080) } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(address, 443, localAddress, 8080) + + // Then + verify { mockSocket.sslParameters = any() } + assertSame(mockSocket, result) + } + + @Test + fun `createSocket with existing socket should customize socket with alternate name`() { + // Given + val mockFactory = mockk() + val mockSSLSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + val existingSocket = mockk() + + every { mockFactory.createSocket(existingSocket, "original.com", 443, true) } returns mockSSLSocket + every { mockSSLSocket.sslParameters } returns mockParams + every { mockSSLSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com") + + // When + val result = alternateFactory.createSocket(existingSocket, "original.com", 443, true) + + // Then + verify { mockSSLSocket.sslParameters = any() } + assertSame(mockSSLSocket, result) + } + + @Test + fun `customizeSocket should set SNI hostname to alternate name for valid hostname`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "valid-hostname.example.com") + + // When & Then - This should work without throwing an exception + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + } + + @Test + fun `customizeSocket should NOT throw IllegalArgumentException for hostname with underscore`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "non_compliant_hostname.example.com") + + // When & Then - This should work without throwing an exception + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + assertEquals(0, mockSocket.sslParameters.serverNames.size) + } + + @Test + fun `createSocket should work with valid international domain names`() { + // Given + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + every { mockFactory.createSocket() } returns mockSocket + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "test-server.example.com") + + // When & Then - This should work as hyphens are valid + assertNotNull(alternateFactory.createSocket()) + verify { mockSocket.sslParameters = any() } + } + + private fun createMockSSLSocketFactory(): SSLSocketFactory { + val mockFactory = mockk() + val mockSocket = mockk(relaxed = true) + val mockParams = mockk(relaxed = true) + + // Setup default behavior + every { mockFactory.defaultCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384") + every { mockFactory.supportedCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256") + + // Make all createSocket methods return our mock socket + every { mockFactory.createSocket() } returns mockSocket + every { mockFactory.createSocket(any(), any()) } returns mockSocket + every { mockFactory.createSocket(any(), any(), any(), any()) } returns mockSocket + every { mockFactory.createSocket(any(), any()) } returns mockSocket + every { + mockFactory.createSocket( + any(), + any(), + any(), + any() + ) + } returns mockSocket + every { mockFactory.createSocket(any(), any(), any(), any()) } returns mockSocket + + // Setup SSL parameters + every { mockSocket.sslParameters } returns mockParams + every { mockSocket.sslParameters = any() } just Runs + + return mockFactory + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt new file mode 100644 index 00000000..f2bb0d27 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/CoderHostnameVerifierTest.kt @@ -0,0 +1,238 @@ +package com.coder.toolbox.util + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.Logger +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import javax.net.ssl.SSLSession +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderHostnameVerifierTest { + + private lateinit var sslSession: SSLSession + private lateinit var x509Certificate: X509Certificate + private lateinit var logger: Logger + private lateinit var verifier: CoderHostnameVerifier + + @BeforeEach + fun setUp() { + sslSession = mockk() + x509Certificate = mockk() + logger = mockk(relaxed = true) + } + + @Test + fun `should return false when no certificates are present`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + every { sslSession.peerCertificates } returns null + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result) + } + + @Test + fun `should return false when certificates array is empty`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + every { sslSession.peerCertificates } returns arrayOf() + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result) + } + + @Test + fun `should return true when SAN contains matching alternate name with underscore`() { + // Given + val alternateNameWithUnderscore = "test_server.internal.com" + verifier = CoderHostnameVerifier(alternateNameWithUnderscore) + + // Mock certificate with SAN containing underscore + val sanEntries = listOf( + listOf(2, "example.com"), // Standard DNS name + listOf(2, "test_server.internal.com"), // SAN with underscore + listOf(2, "api.example.com") // Another DNS name + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should return true when SAN contains matching alternate name with underscore") + } + + @Test + fun `should return false when SAN does not contain matching alternate name`() { + // Given + verifier = CoderHostnameVerifier("missing_host.example.com") + + // Mock certificate without matching SAN + val sanEntries = listOf( + listOf(2, "example.com"), + listOf(2, "api.example.com"), + listOf(2, "different_host.example.com") + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when SAN does not contain matching alternate name") + } + + @Test + fun `should ignore non-DNS SAN entries`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + // Mock certificate with various SAN types + val sanEntries = listOf( + listOf(1, "user@example.com"), // Email (type 1) + listOf(6, "http://example.com"), // URI (type 6) + listOf(7, "192.168.1.1"), // IP Address (type 7) + listOf(2, "test_host.example.com") // DNS Name (type 2) - this should match + ) + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should ignore non-DNS SAN entries and find the matching DNS entry") + } + + @Test + fun `should return false when certificate has no SAN extension`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns null + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when certificate has no SAN extension") + } + + @Test + fun `should handle multiple certificates and find match in second certificate`() { + // Given + verifier = CoderHostnameVerifier("api_server.internal.com") + + val cert1Mock = mockk() + val cert2Mock = mockk() + + // First certificate has no matching SAN + val sanEntries1 = listOf( + listOf(2, "example.com"), + listOf(2, "www.example.com") + ) + + // Second certificate has matching SAN with underscore + val sanEntries2 = listOf( + listOf(2, "internal.com"), + listOf(2, "api_server.internal.com") + ) + + every { sslSession.peerCertificates } returns arrayOf(cert1Mock, cert2Mock) + every { cert1Mock.subjectAlternativeNames } returns sanEntries1 + every { cert2Mock.subjectAlternativeNames } returns sanEntries2 + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should find match in second certificate") + } + + @Test + fun `should handle non-X509 certificates gracefully`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + val nonX509Cert = mockk() // Not an X509Certificate + every { sslSession.peerCertificates } returns arrayOf(nonX509Cert, x509Certificate) + + val sanEntries = listOf( + listOf(2, "test_host.example.com") + ) + every { x509Certificate.subjectAlternativeNames } returns sanEntries + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertTrue(result, "Should skip non-X509 certificates and process X509 certificates") + } + + @Test + fun `should reproduce the underscore bug scenario`() { + // Given - This test reproduces the exact scenario from the bug report + val problematicHostname = "coder_instance.dev.company.com" + verifier = CoderHostnameVerifier(problematicHostname) + + // Mock a certificate that would be valid but contains underscore in SAN + val sanEntries = listOf( + listOf(2, "dev.company.com"), + listOf(2, "coder_instance.dev.company.com"), // This contains underscore + listOf(2, "*.dev.company.com") + ) + + every { x509Certificate.subjectAlternativeNames } returns sanEntries + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + + // When + val result = verifier.verify("dev.company.com", sslSession) + + // Then + assertTrue(result, "Should successfully verify hostname with underscore in SAN") + + // Additional verification that the problematic hostname would be found + val foundHostnames = mutableListOf() + sanEntries.forEach { entry -> + if (entry[0] == 2) { // DNS name type + foundHostnames.add(entry[1] as String) + } + } + + assertTrue( + foundHostnames.any { it.equals(problematicHostname, ignoreCase = true) }, + "Certificate should contain the problematic hostname with underscore" + ) + } + + @Test + fun `should handle edge case with empty SAN list`() { + // Given + verifier = CoderHostnameVerifier("test_host.example.com") + + every { sslSession.peerCertificates } returns arrayOf(x509Certificate) + every { x509Certificate.subjectAlternativeNames } returns emptyList() + + // When + val result = verifier.verify("example.com", sslSession) + + // Then + assertFalse(result, "Should return false when SAN list is empty") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index b26acde5..326fce04 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -16,15 +16,28 @@ import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.mockk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.DisplayName import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull internal class CoderProtocolHandlerTest { + + private companion object { + val AGENT_RIKER = AgentTestData(name = "Riker", id = "9a920eee-47fb-4571-9501-e4b3120c12f2") + val AGENT_BILL = AgentTestData(name = "Bill", id = "fb3daea4-da6b-424d-84c7-36b90574cfef") + val AGENT_BOB = AgentTestData(name = "Bob", id = "b0e4c54d-9ba9-4413-8512-11ca1e826a24") + + val ALL_AGENTS = mapOf( + AGENT_BOB.name to AGENT_BOB.id, + AGENT_BILL.name to AGENT_BILL.id, + AGENT_RIKER.name to AGENT_RIKER.id + ) + + val SINGLE_AGENT = mapOf(AGENT_BOB.name to AGENT_BOB.id) + } + private val context = CoderToolboxContext( mockk(relaxed = true), mockk(), @@ -41,133 +54,174 @@ internal class CoderProtocolHandlerTest { ) private val protocolHandler = CoderProtocolHandler( - context, - DialogUi(context), - MutableStateFlow(false) + context ) - private val agents = - mapOf( - "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - "agent_name_bill" to "fb3daea4-da6b-424d-84c7-36b90574cfef", - "agent_name_riker" to "9a920eee-47fb-4571-9501-e4b3120c12f2", - ) - private val agentBob = - mapOf( - "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ) - @Test - @DisplayName("given a ws with multiple agents, expect the correct agent to be resolved if it matches the agent_name query param") - fun getMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - - val tests = - listOf( - Pair( - mapOf("agent_name" to "agent_name_riker"), - "9a920eee-47fb-4571-9501-e4b3120c12f2" - ), - Pair( - mapOf("agent_name" to "agent_name_bill"), - "fb3daea4-da6b-424d-84c7-36b90574cfef" - ), - Pair( - mapOf("agent_name" to "agent_name_bob"), - "b0e4c54d-9ba9-4413-8512-11ca1e826a24" - ) + fun `given a workspace with multiple agents when getMatchingAgent is called with a valid agent name then it correctly resolves resolves an agent`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentMatchTestCase( + "resolves agent with name Riker", + mapOf("agent_name" to AGENT_RIKER.name), + AGENT_RIKER.uuid + ), + AgentMatchTestCase( + "resolves agent with name Bill", + mapOf("agent_name" to AGENT_BILL.name), + AGENT_BILL.uuid + ), + AgentMatchTestCase( + "resolves agent with name Bob", + mapOf("agent_name" to AGENT_BOB.name), + AGENT_BOB.uuid ) + ) + runBlocking { - tests.forEach { - assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + testCases.forEach { testCase -> + assertEquals( + testCase.expectedAgentId, + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" + ) } } } @Test - @DisplayName("given a ws with only multiple agents expect the agent resolution to fail if none match the agent_name query param") - fun failsToGetMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - val tests = - listOf( - Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_name" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_name" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_name" to "not-an-agent-name"), IllegalArgumentException::class, "agent with ID"), - Triple( - mapOf("agent_name" to "agent_name_homer"), - IllegalArgumentException::class, - "agent with name" - ) + fun `given a workspace with multiple agents when getMatchingAgent is called with invalid agent names then no agent is resolved`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentNullResultTestCase( + "empty parameters (i.e. no agent name) does not return any agent", + emptyMap() + ), + AgentNullResultTestCase( + "empty agent_name does not return any agent", + mapOf("agent_name" to "") + ), + AgentNullResultTestCase( + "null agent_name does not return any agent", + mapOf("agent_name" to null) + ), + AgentNullResultTestCase( + "non-existent agent does not return any agent", + mapOf("agent_name" to "agent_name_homer") + ), + AgentNullResultTestCase( + "UUID instead of name does not return any agent", + mapOf("agent_name" to "not-an-agent-name") ) + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + testCases.forEach { testCase -> + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" + ) } } } @Test - @DisplayName("given a ws with only one agent, the agent is selected even when agent_name query param was not provided") - fun getsFirstAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = agentBob) - val tests = - listOf( + fun `given a workspace with a single agent when getMatchingAgent is called with an empty agent name then the default agent is resolved`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCases = listOf( + AgentMatchTestCase( + "empty parameters (i.e. no agent name) auto-selects the one and only agent available", emptyMap(), + AGENT_BOB.uuid + ), + AgentMatchTestCase( + "empty agent_name auto-selects the one and only agent available", mapOf("agent_name" to ""), - mapOf("agent_name" to null) + AGENT_BOB.uuid + ), + AgentMatchTestCase( + "null agent_name auto-selects the one and only agent available", + mapOf("agent_name" to null), + AGENT_BOB.uuid ) + ) + runBlocking { - tests.forEach { + testCases.forEach { testCase -> assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - protocolHandler.getMatchingAgent( - it, - ws, - )?.id, + testCase.expectedAgentId, + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" ) } } } @Test - @DisplayName("given a ws with only one agent, the agent is NOT selected when agent_name query param was provided but does not match") - fun failsToGetAgentWhenOnlyOne() { - val wsWithAgentBob = DataGen.workspace("ws", agents = agentBob) - val tests = - listOf( - Triple( - mapOf("agent_name" to "agent_name_garfield"), - IllegalArgumentException::class, - "agent with name" - ), - ) + fun `given a workspace with a single agent when getMatchingAgent is called with an invalid agent name then no agent is resolved`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCase = AgentNullResultTestCase( + "non-matching agent_name with single agent", + mapOf("agent_name" to "agent_name_garfield") + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, wsWithAgentBob)) - } + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws), + "Failed: ${testCase.description}" + ) } } @Test - @DisplayName("fails to resolve any agent when the workspace has no agents") - fun failsToGetAgentWhenWorkspaceHasNoAgents() { - val wsWithoutAgents = DataGen.workspace("ws") - val tests = - listOf( - Triple(emptyMap(), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_name" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_name" to null), IllegalArgumentException::class, "has no agents"), - Triple( - mapOf("agent_name" to "agent_name_riker"), - IllegalArgumentException::class, - "has no agents" - ), + fun `given a workspace with no agent when getMatchingAgent is called then no agent is resolved`() { + val ws = DataGen.workspace("ws") + + val testCases = listOf( + AgentNullResultTestCase( + "empty parameters (i.e. no agent name) does not return any agent", + emptyMap() + ), + AgentNullResultTestCase( + "empty agent_name does not return any agent", + mapOf("agent_name" to "") + ), + AgentNullResultTestCase( + "null agent_name does not return any agent", + mapOf("agent_name" to null) + ), + AgentNullResultTestCase( + "valid agent_name does not return any agent", + mapOf("agent_name" to AGENT_RIKER.name) ) + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, wsWithoutAgents)) + testCases.forEach { testCase -> + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws), + "Failed: ${testCase.description}" + ) } } } -} + + internal data class AgentTestData(val name: String, val id: String) { + val uuid: UUID get() = UUID.fromString(id) + } + + internal data class AgentMatchTestCase( + val description: String, + val params: Map, + val expectedAgentId: UUID + ) + + internal data class AgentNullResultTestCase( + val description: String, + val params: Map + ) +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index af1b4efd..eebd4247 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -9,21 +9,21 @@ internal class URLExtensionsTest { @Test fun testToURL() { assertEquals( - URL("https", "localhost", 8080, "/path"), - "https://localhost:8080/path".toURL(), + expected = URI.create("https://localhost:8080/path").toURL(), + actual = "https://localhost:8080/path".toURL(), ) } @Test fun testWithPath() { assertEquals( - URL("https", "localhost", 8080, "/foo/bar"), - URL("https", "localhost", 8080, "/").withPath("/foo/bar"), + expected = "https://localhost:8080/foo/bar".toURL(), + actual = "https://localhost:8080/".toURL().withPath("/foo/bar"), ) assertEquals( - URL("https", "localhost", 8080, "/foo/bar"), - URL("https", "localhost", 8080, "/old/path").withPath("/foo/bar"), + expected = "https://localhost:8080/foo/bar".toURL(), + actual = "https://localhost:8080/old/path".toURL().withPath("/foo/bar"), ) } diff --git a/src/test/resources/extension.json b/src/test/resources/extension.json new file mode 100644 index 00000000..3f897e2d --- /dev/null +++ b/src/test/resources/extension.json @@ -0,0 +1,4 @@ +{ + "id": "com.coder.toolbox", + "version": "development" +} \ No newline at end of file