diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a55c794..cc1d4003 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@v5 - - 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@v5 # 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@v5 # 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..40c2421f 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@v5 - 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..6918c4e6 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@v5 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 69ccd25c..40ad0740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,121 @@ ### Added +- application name can now be displayed as the main title page instead of the URL + +### 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 + +### Changed + +- URL validation is stricter in the connection screen and URI protocol handler +- support for verbose logging a sanitized version of the REST API request and responses + +### Fixed + +- remote IDE reconnects automatically after plugin upgrade + +## 0.6.0 - 2025-07-25 + +### Changed + +- improved workflow when network connection is flaky + +## 0.5.2 - 2025-07-22 + +### Fixed + +- fix class cast exception during signature verification +- the correct CLI signature for Windows is now downloaded + +## 0.5.1 - 2025-07-21 + +### Added + +- support for certificate based authentication + +## 0.5.0 - 2025-07-17 + +### Added + - support for matching workspace agent in the URI via the agent name - support for checking if CLI is signed diff --git a/JETBRAINS_COMPLIANCE.md b/JETBRAINS_COMPLIANCE.md index 306d6844..91162edc 100644 --- a/JETBRAINS_COMPLIANCE.md +++ b/JETBRAINS_COMPLIANCE.md @@ -39,8 +39,6 @@ This configuration includes JetBrains-specific rules that check for: - **ForbiddenImport**: Detects potentially bundled libraries - **Standard code quality rules**: Complexity, naming, performance, etc. - - ## CI/CD Integration The GitHub Actions workflow `.github/workflows/jetbrains-compliance.yml` runs compliance checks on every PR and push. @@ -55,8 +53,6 @@ The GitHub Actions workflow `.github/workflows/jetbrains-compliance.yml` runs co open build/reports/detekt/detekt.html ``` - - ## Understanding Results ### Compliance Check Results diff --git a/README.md b/README.md index 41d430d8..2d50806d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,69 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable experience, it’s recommended to ensure the workspace is running prior to initiating the connection. +## GPG Signature Verification + +The Coder Toolbox plugin starting with version *0.5.0* implements a comprehensive GPG signature verification system to +ensure the authenticity and integrity of downloaded Coder CLI binaries. This security feature helps protect users from +running potentially malicious or tampered binaries. + +### How It Works + +1. **Binary Download**: When connecting to a Coder deployment, the plugin downloads the appropriate Coder CLI binary for + the user's operating system and architecture from the deployment's `/bin/` endpoint. + +2. **Signature Download**: After downloading the binary, the plugin attempts to download the corresponding `.asc` + signature file from the same location. The signature file is named according to the binary (e.g., + `coder-linux-amd64.asc` for `coder-linux-amd64`). + +3. **Fallback Signature Sources**: If the signature is not available from the deployment, the plugin can optionally fall + back to downloading signatures from `releases.coder.com`. This is controlled by the `fallbackOnCoderForSignatures` + setting. + +4. **GPG Verification**: The plugin uses the BouncyCastle library to verify the detached GPG signature against the + downloaded binary using Coder's trusted public key. + +5. **User Interaction**: If signature verification fails or signatures are unavailable, the plugin presents security + warnings to users, allowing them to accept the risk and continue or abort the operation. + +### Verification Process + +The verification process involves several components: + +- **`GPGVerifier`**: Handles the core GPG signature verification logic using BouncyCastle +- **`VerificationResult`**: Represents the outcome of verification (Valid, Invalid, Failed, SignatureNotFound) +- **`CoderDownloadService`**: Manages downloading both binaries and their signatures +- **`CoderCLIManager`**: Orchestrates the download and verification workflow + +### Configuration Options + +Users can control signature verification behavior through plugin settings: + +- **`disableSignatureVerification`**: When enabled, skips all signature verification. This is useful for clients running + custom CLI builds, or customers with old deployment versions that don't have a signature published on + `releases.coder.com`. +- **`fallbackOnCoderForSignatures`**: When enabled, allows downloading signatures from `releases.coder.com` if not + available from the deployment. + +### Security Considerations + +- The plugin embeds Coder's trusted public key in the plugin resources +- Verification uses detached signatures, which are more secure than attached signatures +- Users are warned about security risks when verification fails +- The system gracefully handles cases where signatures are unavailable +- All verification failures are logged for debugging purposes + +### Error Handling + +The system handles various failure scenarios: + +- **Missing signatures**: Prompts user to accept risk or abort +- **Invalid signatures**: Warns user about potential tampering and prompts user to accept risk or abort +- **Verification failures**: Prompts user to accept risk or abort + +This signature verification system ensures that users can trust the Coder CLI binaries they download through the plugin, +protecting against supply chain attacks and ensuring binary integrity. + ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy and verify that @@ -157,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 @@ -194,6 +278,64 @@ via Toolbox App Menu > About > Show log files. Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. +### HTTP Request Logging + +The Coder Toolbox plugin includes comprehensive HTTP request logging capabilities to help diagnose API communication +issues with Coder deployments. +This feature allows you to monitor all HTTP requests and responses made by the plugin. + +#### Configuring HTTP Logging + +You can configure HTTP logging verbosity through the Coder Settings page: + +1. Navigate to the Coder Workspaces page +2. Click on the deployment action menu (three dots) +3. Select "Settings" +4. Find the "HTTP logging level" dropdown + +#### Available Logging Levels + +The plugin supports four levels of HTTP logging verbosity: + +- **None**: No HTTP request/response logging (default) +- **Basic**: Logs HTTP method, URL, and response status code +- **Headers**: Logs basic information plus sanitized request and response headers +- **Body**: Logs headers plus request and response body content + +#### Log Output Format + +HTTP logs follow this format: + +``` +request --> GET https://your-coder-deployment.com/api/v2/users/me +User-Agent: Coder Toolbox/1.0.0 (darwin; amd64) +Coder-Session-Token: + +response <-- 200 https://your-coder-deployment.com/api/v2/users/me +Content-Type: application/json +Content-Length: 245 + +{"id":"12345678-1234-1234-1234-123456789012","username":"coder","email":"coder@example.com"} +``` + +#### Use Cases + +HTTP logging is particularly useful for: + +- **API Debugging**: Diagnosing issues with Coder API communication +- **Authentication Problems**: Troubleshooting token or certificate authentication issues +- **Network Issues**: Identifying connectivity problems with Coder deployments +- **Performance Analysis**: Monitoring request/response times and payload sizes + +#### Troubleshooting with HTTP Logs + +When reporting issues, include HTTP logs to help diagnose: + +1. **Authentication Failures**: Check for 401/403 responses and token headers +2. **Network Connectivity**: Look for connection timeouts or DNS resolution issues +3. **API Compatibility**: Verify request/response formats match expected API versions +4. **Proxy Issues**: Monitor proxy authentication and routing problems + ## Coder Settings The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data @@ -218,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 9513b306..447537eb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.5.0 +version=0.7.2 group=com.coder.toolbox -name=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..a40c6433 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.49" 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.4.0" gettext = "0.7.0" -plugin-structure = "3.310" -mockk = "1.14.4" +plugin-structure = "3.320" +mockk = "1.14.6" detekt = "1.23.8" -bouncycastle = "1.81" +bouncycastle = "1.82" [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 08f5a07b..4b9c6073 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 @@ -34,6 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -67,75 +71,123 @@ class CoderRemoteEnvironment( private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + private val startIsInProgress = AtomicBoolean(false) + + init { + if (context.settingsStore.shouldAutoConnect(id)) { + context.logger.info("resuming SSH connection to $id — last session was still active.") + startSshConnection() + } + } fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) private fun getAvailableActions(): List { - val actions = mutableListOf() + 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) - } + actions.add(Action(context, "Open web terminal") { + 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.desktop.browse( + url + ) { context.ui.showErrorInfoPopup(it) } } - }) + ) + + actions.add(Action(context, "View template") { + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) + } + } + ) if (wsRawStatus.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") { + 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") { + try { + // needed in order to make sure Queuing is not overridden by the + // general polling loop with the `Stopped` state + startIsInProgress.set(true) + val startJob = 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 + while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) { + state.update { + WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context) + } + delay(1.seconds) + } + startIsInProgress.set(false) + // retrieve the status again and update the status + update(client.workspace(workspace.id), agent) + } finally { + startIsInProgress.set(false) } - }) + } + ) } } if (wsRawStatus.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") { + val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } - }) + ) + } + actions.add(Action(context, "Stop") { + tryStopSshConnection() + + 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 (wsRawStatus.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 (wsRawStatus.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 + } + deleteWorkspace() + } + }) return actions } @@ -158,10 +210,11 @@ class CoderRemoteEnvironment( override fun beforeConnection() { context.logger.info("Connecting to $id...") isConnected.update { true } + context.settingsStore.updateAutoConnect(this.id, true) 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...") @@ -180,12 +233,9 @@ class CoderRemoteEnvironment( } context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id") try { - val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) - if (metrics == null) { - return@launch - } + val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) ?: return@launch context.logger.debug("$id metrics: $metrics") - additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty()) + additionalEnvironmentInformation[context.i18n.ptrl("Network Status")] = metrics.toPretty() } catch (e: Exception) { context.logger.error( e, @@ -203,6 +253,10 @@ class CoderRemoteEnvironment( pollJob?.cancel() this.connectionRequest.update { false } isConnected.update { false } + if (isManual) { + // if the user manually disconnects the ssh connection we should not connect automatically + context.settingsStore.updateAutoConnect(this.id, false) + } context.logger.info("Disconnected from $id") } @@ -210,6 +264,10 @@ class CoderRemoteEnvironment( * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { + if (startIsInProgress.get()) { + context.logger.info("Skipping update for $id - workspace start is in progress") + return + } this.workspace = workspace this.agent = agent wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) @@ -218,7 +276,7 @@ class CoderRemoteEnvironment( actionsList.update { getAvailableActions() } - context.cs.launch { + context.cs.launch(CoroutineName("Workspace Status Updater")) { state.update { wsRawStatus.toRemoteEnvironmentState(context) } @@ -253,7 +311,7 @@ class CoderRemoteEnvironment( */ fun startSshConnection(): Boolean { if (wsRawStatus.ready() && !isConnected.value) { - context.cs.launch { + context.cs.launch(CoroutineName("SSH Connection Trigger")) { connectionRequest.update { true } @@ -263,43 +321,32 @@ class CoderRemoteEnvironment( 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 (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == 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 3e3172a7..6084880e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -11,8 +11,10 @@ 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 +23,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 +37,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI -import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -54,9 +55,8 @@ 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 @@ -64,143 +64,156 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(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, dialogUi, settingsPage, visibilityState, isInitialized) + + override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") + override val environments: MutableStateFlow>> = MutableStateFlow( + LoadableState.Loading + ) + + private val errorBuffer = mutableListOf() /** * With the provided client, start polling for workspaces. Every time a new * 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, going to re-initialize the http client...") - client.setupSession() - } else { - context.logger.error(ex, "workspace polling error encountered, trying to auto-login") - if (ex is APIResponseException && ex.isTokenExpired) { - WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true - } - close() - // force auto-login - firstRun = true - context.envPageManager.showPluginEnvironmentsPage() + 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 + } + } } - } - // TODO: Listening on a web socket might be better? - 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() { - // Keep the URL and token to make it easy to log back in, but set - // rememberMe to false so we do not try to automatically log in. - context.secrets.rememberMe = false + context.logger.info("Logging out ${client?.me?.username}...") WorkspaceConnectionManager.reset() close() + context.logger.info("User ${client?.me?.username} logged out successfully") } /** @@ -219,15 +232,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) }, ) @@ -239,13 +254,20 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { - pollJob?.cancel() - client?.close() + 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") + } + client = null lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - client = null - CoderCliSetupWizardState.resetSteps() + CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -297,6 +319,11 @@ class CoderRemoteProvider( visibilityState.update { visibility } + if (visibility.providerVisible) { + context.cs.launch(CoroutineName("Notify Plugin Visibility")) { + triggerProviderVisible.send(true) + } + } } /** @@ -305,49 +332,37 @@ class CoderRemoteProvider( override suspend fun handleUri(uri: URI) { try { linkHandler.handle( - uri, shouldDoAutoSetup(), - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - true - } - }, - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - false - } - } + uri, + shouldDoAutoSetup() ) { restClient, cli -> - // stop polling and de-initialize resources + context.logger.info("Stopping workspace polling and de-initializing resources") close() isInitialized.update { false } - // start initialization with the new settings + context.logger.info("Starting initialization with the new settings") this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } environments.showLoadingMessage() pollJob = poll(restClient, cli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") isInitialized.waitForTrue() } } 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 ?: "" ) - } finally { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - false - } + context.envPageManager.showPluginEnvironmentsPage() } } @@ -360,22 +375,26 @@ class CoderRemoteProvider( override fun getOverrideUiPage(): UiPage? { // Show the setup page if we have not configured the client yet. if (client == null) { - val errorBuffer = mutableListOf() // When coming back to the application, initializeSession immediately. - val autoSetup = shouldDoAutoSetup() - context.secrets.lastToken.let { lastToken -> - context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { - try { - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) - } catch (ex: Exception) { - errorBuffer.add(ex) - } + if (shouldDoAutoSetup()) { + try { + CoderCliSetupContext.apply { + url = context.deploymentUrl + token = context.secrets.tokenFor(context.deploymentUrl) } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = false, + onConnect = ::onConnect + ) + } catch (ex: Exception) { + errorBuffer.add(ex) + } finally { + firstRun = false } } - firstRun = false // Login flow. val setupWizardPage = @@ -384,27 +403,44 @@ class CoderRemoteProvider( errorBuffer.forEach { setupWizardPage.notify("Error encountered", it) } + errorBuffer.clear() // and now reset the errors, otherwise we show it every time on the screen return setupWizardPage } return null } - private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true + /** + * 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 && (canAutoLogin() || !settings.requireTokenAuth) + + 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) - // Currently we always remember, but this could be made an option. - context.secrets.rememberMe = true + context.settingsStore.updateLastUsedUrl(client.url) + if (context.settingsStore.requireTokenAuth) { + 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() + pollJob?.let { + it.cancel() + context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") + } 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() { @@ -412,6 +448,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 177ba814..eb289af6 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -12,14 +12,14 @@ import com.coder.toolbox.cli.gpg.GPGVerifier import com.coder.toolbox.cli.gpg.VerificationResult import com.coder.toolbox.cli.gpg.VerificationResult.Failed import com.coder.toolbox.cli.gpg.VerificationResult.Invalid +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW -import com.coder.toolbox.util.CoderHostnameVerifier import com.coder.toolbox.util.InvalidVersionException import com.coder.toolbox.util.SemVer -import com.coder.toolbox.util.coderSocketFactory -import com.coder.toolbox.util.coderTrustManagers import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand import com.coder.toolbox.util.safeHost @@ -29,7 +29,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 +36,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 +125,7 @@ data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, val wildcardSsh: Boolean = false, + val buildReason: Boolean = false, ) /** @@ -148,13 +147,14 @@ class CoderCLIManager( val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") private fun createDownloadService(): CoderDownloadService { - val okHttpClient = OkHttpClient.Builder() - .sslSocketFactory( - coderSocketFactory(context.settingsStore.tls), - coderTrustManagers(context.settingsStore.tls.caPath)[0] as X509TrustManager - ) - .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) - .build() + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + val okHttpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) val retrofit = Retrofit.Builder() .baseUrl(deploymentURL.toString()) @@ -181,6 +181,12 @@ class CoderCLIManager( } } + if (context.settingsStore.disableSignatureVerification) { + downloader.commit() + context.logger.info("Skipping over CLI signature verification, it is disabled by the user") + return true + } + var signatureResult = withContext(Dispatchers.IO) { downloader.downloadSignature(showTextProgress) } @@ -277,8 +283,8 @@ class CoderCLIManager( } else -> { - UnsignedBinaryExecutionDeniedException((result as Failed).error.message) - val failure = result as DownloadResult.Failed + val failure = result as Failed + UnsignedBinaryExecutionDeniedException(result.error.message) context.logger.error(failure.error, "Failed to verify signature for ${cliResult.dst}") } } @@ -299,6 +305,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. * @@ -310,6 +335,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") } /** @@ -348,24 +374,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 @@ -467,7 +491,7 @@ class CoderCLIManager( */ private fun writeSSHConfig(contents: String?) { if (contents != null) { - if (!context.settingsStore.sshConfigPath.isNullOrBlank()) { + if (context.settingsStore.sshConfigPath.isNotBlank()) { val sshConfPath = Path.of(context.settingsStore.sshConfigPath) sshConfPath.parent.toFile().mkdirs() sshConfPath.toFile().writeText(contents) @@ -492,9 +516,9 @@ class CoderCLIManager( throw MissingVersionException("No version found in output") } return SemVer.parse(json.version) - } catch (exception: JsonDataException) { + } catch (_: JsonDataException) { throw MissingVersionException("No version found in output") - } catch (exception: EOFException) { + } catch (_: EOFException) { throw MissingVersionException("No version found in output") } } @@ -532,7 +556,7 @@ class CoderCLIManager( val buildVersion = try { SemVer.parse(rawBuildVersion) - } catch (e: InvalidVersionException) { + } catch (_: InvalidVersionException) { context.logger.info("Got invalid build version: $rawBuildVersion") return null } @@ -565,7 +589,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..86474d9c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -0,0 +1,56 @@ +package com.coder.toolbox.sdk + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.coderTrustManagers +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import javax.net.ssl.X509TrustManager + +object CoderHttpClientBuilder { + fun build( + context: CoderToolboxContext, + interceptors: List + ): OkHttpClient { + val settings = context.settingsStore.readOnly() + + val socketFactory = coderSocketFactory(settings.tls) + val trustManagers = coderTrustManagers(settings.tls.caPath) + var 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(socketFactory, trustManagers[0] as X509TrustManager) + .hostnameVerifier(CoderHostnameVerifier(settings.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 9aa3dfbd..7023c764 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,30 +3,24 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter +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.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.squareup.moshi.Moshi -import okhttp3.Credentials import okhttp3.OkHttpClient import retrofit2.Response import retrofit2.Retrofit @@ -34,7 +28,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. @@ -47,19 +40,19 @@ open class CoderRestClient( val token: String?, private val pluginVersion: String = "development", ) { - private val settings = context.settingsStore.readOnly() private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade lateinit var me: User lateinit var buildVersion: String + lateinit var appName: String init { setupSession() } - fun setupSession() { + private fun setupSession() { moshi = Moshi.Builder() .add(ArchConverter()) @@ -67,69 +60,31 @@ open class CoderRestClient( .add(OSConverter()) .add(UUIDConverter()) .build() - - val socketFactory = coderSocketFactory(settings.tls) - val trustManagers = coderTrustManagers(settings.tls.caPath) - var builder = OkHttpClient.Builder() - - if (context.proxySettings.getProxy() != null) { - context.logger.debug("proxy: ${context.proxySettings.getProxy()}") - builder.proxy(context.proxySettings.getProxy()) - } else if (context.proxySettings.getProxySelector() != null) { - context.logger.debug("proxy selector: ${context.proxySettings.getProxySelector()}") - builder.proxySelector(context.proxySettings.getProxySelector()!!) - } - - // 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 interceptors = buildList { + if (context.settingsStore.requireTokenAuth) { + if (token.isNullOrBlank()) { + throw IllegalStateException("Token is required for $url deployment") + } + add(Interceptors.tokenAuth(token)) } - val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password) - response.request.newBuilder() - .header("Proxy-Authorization", credentials) - .build() + add((Interceptors.userAgent(pluginVersion))) + add(Interceptors.externalHeaders(context, url)) + add(Interceptors.logging(context)) } - if (token != null) { - builder = builder.addInterceptor { - it.proceed( - it.request().newBuilder().addHeader("Coder-Session-Token", token).build() - ) - } - } - - 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) - } - .build() + httpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(moshi) + ) + ) .build().create(CoderV2RestFacade::class.java) } @@ -141,6 +96,7 @@ open class CoderRestClient( suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version + appName = appearance().applicationName return me } @@ -148,7 +104,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( @@ -159,7 +115,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" + } } /** @@ -177,7 +154,9 @@ open class CoderRestClient( ) } - return workspacesResponse.body()!!.workspaces + return requireNotNull(workspacesResponse.body()?.workspaces) { + "Successful response returned null body or workspaces" + } } /** @@ -185,33 +164,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" + } } /** @@ -232,7 +197,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 { @@ -245,7 +213,10 @@ open class CoderRestClient( buildInfoResponse.parseErrorBody(moshi) ) } - return buildInfoResponse.body()!! + + return requireNotNull(buildInfoResponse.body()) { + "Successful response returned null body or build info" + } } /** @@ -261,14 +232,23 @@ 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, WorkspaceTransition.START) + val buildRequest = CreateWorkspaceBuildRequest( + null, + WorkspaceTransition.START, + null, + WorkspaceBuildReason.JETBRAINS_CONNECTION + ) val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { throw APIResponseException( @@ -278,7 +258,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -294,7 +277,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -336,7 +322,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/convertors/LoggingConverterFactory.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt new file mode 100644 index 00000000..839d7530 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class LoggingConverterFactory private constructor( + private val context: CoderToolboxContext, + private val delegate: Converter.Factory, +) : Converter.Factory() { + + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter? { + // Get the delegate converter + val delegateConverter = delegate.responseBodyConverter(type, annotations, retrofit) + ?: return null + + @Suppress("UNCHECKED_CAST") + return LoggingMoshiConverter(context, delegateConverter as Converter) + } + + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + retrofit: Retrofit + ): Converter<*, RequestBody>? { + return delegate.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit) + } + + override fun stringConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter<*, String>? { + return delegate.stringConverter(type, annotations, retrofit) + } + + companion object { + fun wrap( + context: CoderToolboxContext, + delegate: Converter.Factory, + ): LoggingConverterFactory { + return LoggingConverterFactory(context, delegate) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt new file mode 100644 index 00000000..9cc548ab --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt @@ -0,0 +1,34 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Converter + +class LoggingMoshiConverter( + private val context: CoderToolboxContext, + private val delegate: Converter +) : Converter { + + override fun convert(value: ResponseBody): Any? { + val bodyString = value.string() + + return try { + // Parse with Moshi + delegate.convert(bodyString.toResponseBody(value.contentType())) + } catch (e: Exception) { + // Log the raw content that failed to parse + context.logger.error( + """ + |Moshi parsing failed: + |Content-Type: ${value.contentType()} + |Content: $bodyString + |Error: ${e.message} + """.trimMargin() + ) + + // Re-throw so the onFailure callback still gets called + throw e + } + } +} \ No newline at end of file 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/interceptors/LoggingInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt new file mode 100644 index 00000000..4bbb1b96 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt @@ -0,0 +1,120 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import java.nio.charset.StandardCharsets + +private val SENSITIVE_HEADERS = setOf("Coder-Session-Token", "Proxy-Authorization") + +class LoggingInterceptor(private val context: CoderToolboxContext) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val logLevel = context.settingsStore.httpClientLogLevel + if (logLevel == HttpLoggingVerbosity.NONE) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + logRequest(request, logLevel) + + val response = chain.proceed(request) + logResponse(response, request, logLevel) + + return response + } + + private fun logRequest(request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("request --> ${request.method} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${request.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + request.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } + + private fun logResponse(response: Response, request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("response <-- ${response.code} ${response.message} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${response.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + response.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } +} + +// Extension functions for cleaner code +private fun Headers.sanitized(): String = buildString { + this@sanitized.forEach { (name, value) -> + val displayValue = if (name in SENSITIVE_HEADERS) "" else value + append("$name: $displayValue\n") + } +} + +private fun RequestBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun ResponseBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val source = source() + source.request(Long.MAX_VALUE) + source.buffer.clone().readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun MediaType?.isPrintable(): Boolean = when { + this == null -> false + type == "text" -> true + subtype == "json" || subtype.endsWith("+json") -> true + else -> false +} + +private fun Long.formatBytes(): String = when { + this < 0 -> "unknown" + this < 1024 -> "${this}B" + this < 1024 * 1024 -> "${this / 1024}KB" + this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB" + else -> "${this / (1024 * 1024 * 1024)}GB" +} \ 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/CreateWorkspaceBuildRequest.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt index a2f1ca24..53c70c89 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt @@ -10,7 +10,8 @@ data class CreateWorkspaceBuildRequest( @Json(name = "template_version_id") val templateVersionID: UUID?, // Use to start, stop and delete the workspace. @Json(name = "transition") val transition: WorkspaceTransition, - @Json(name = "orphan") var orphan: Boolean? = null + @Json(name = "orphan") var orphan: Boolean? = null, + @Json(name = "reason") var reason: WorkspaceBuildReason? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -21,6 +22,7 @@ data class CreateWorkspaceBuildRequest( if (templateVersionID != other.templateVersionID) return false if (transition != other.transition) return false if (orphan != other.orphan) return false + if (reason != other.reason) return false return true } @@ -28,6 +30,7 @@ data class CreateWorkspaceBuildRequest( var result = orphan?.hashCode() ?: 0 result = 31 * result + (templateVersionID?.hashCode() ?: 0) result = 31 * result + transition.hashCode() + result = 31 * result + (reason?.hashCode() ?: 0) return result } } 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..a7752a89 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,41 @@ 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 = "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/sdk/v2/models/WorkspaceBuildReason.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt new file mode 100644 index 00000000..390c934b --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt @@ -0,0 +1,7 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json + +enum class WorkspaceBuildReason { + @Json(name = "jetbrains_connection") JETBRAINS_CONNECTION, +} \ 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 a6d61439..edf4801f 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 @@ -29,10 +41,20 @@ interface ReadOnlyCoderSettings { val binaryDirectory: String? /** - * Controls whether we fall back release.coder.com + * Controls whether we verify the cli signature + */ + val disableSignatureVerification: Boolean + + /** + * Controls whether we fall back on release.coder.com for signatures if signature validation is enabled */ val fallbackOnCoderForSignatures: SignatureFallbackStrategy + /** + * Controls the logging for the rest client. + */ + val httpClientLogLevel: HttpLoggingVerbosity + /** * Default CLI binary name based on OS and architecture */ @@ -121,6 +143,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 @@ -146,6 +179,11 @@ interface ReadOnlyCoderSettings { * Return the URL and token from the config, if they exist. */ fun readConfig(dir: Path): Pair + + /** + * Returns whether the SSH connection should be automatically established. + */ + fun shouldAutoConnect(workspaceId: String): Boolean } /** @@ -206,4 +244,32 @@ enum class SignatureFallbackStrategy { else -> NOT_CONFIGURED } } +} + +enum class HttpLoggingVerbosity { + NONE, + + /** + * Logs URL, method, and status + */ + BASIC, + + /** + * Logs BASIC + sanitized headers + */ + HEADERS, + + /** + * Logs HEADERS + body content + */ + BODY; + + companion object { + fun fromValue(value: String?): HttpLoggingVerbosity = when (value?.lowercase(getDefault())) { + "basic" -> BASIC + "headers" -> HEADERS + "body" -> BODY + else -> NONE + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index 3170a060..a5466b41 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -8,25 +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) - var rememberMe: Boolean - get() = get("remember-me").toBoolean() - set(value) = set("remember-me", value.toString()) + @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 a57ff452..ed8f009c 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.HttpLoggingVerbosity import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings import com.coder.toolbox.settings.SignatureFallbackStrategy @@ -35,11 +36,17 @@ class CoderSettingsStore( ) : 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 + get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false override val fallbackOnCoderForSignatures: SignatureFallbackStrategy get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) + override val httpClientLogLevel: HttpLoggingVerbosity + get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) @@ -74,6 +81,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. */ @@ -142,10 +154,22 @@ class CoderSettingsStore( } } + override fun shouldAutoConnect(workspaceId: String): Boolean { + return store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"]?.toBooleanStrictOrNull() ?: false + } + // a readonly cast 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 } @@ -162,6 +186,10 @@ class CoderSettingsStore( store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() } + fun updateDisableSignatureVerification(shouldDisableSignatureVerification: Boolean) { + store[DISABLE_SIGNATURE_VALIDATION] = shouldDisableSignatureVerification.toString() + } + fun updateSignatureFallbackStrategy(fallback: Boolean) { store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { true -> SignatureFallbackStrategy.ALLOW.toString() @@ -169,6 +197,11 @@ class CoderSettingsStore( } } + fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) { + if (level == null) return + store[HTTP_CLIENT_LOG_LEVEL] = level.toString() + } + fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } @@ -213,6 +246,10 @@ class CoderSettingsStore( store[SSH_CONFIG_OPTIONS] = options } + fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { + store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(env.get("LOCALAPPDATA"), "coder-toolbox") @@ -250,42 +287,17 @@ class CoderSettingsStore( /** * Return the name of the binary (with extension) for the provided OS and architecture. */ - private fun getCoderCLIForOS( - os: OS?, - arch: Arch?, - ): String { + private fun getCoderCLIForOS(os: OS?, arch: Arch?): String { logger.debug("Resolving binary for $os $arch") - return buildCoderFileName(os, arch) - } - /** - * Return the name of the signature file (.asc) for the provided OS and architecture. - */ - private fun getCoderSignatureForOS( - os: OS?, - arch: Arch?, - ): String { - logger.debug("Resolving signature for $os $arch") - return buildCoderFileName(os, arch, true) - } - - /** - * Build the coder file name based on OS, architecture, and whether it's a signature file. - */ - private fun buildCoderFileName( - os: OS?, - arch: Arch?, - isSignature: Boolean = false - ): String { - if (os == null) { - logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") - return if (isSignature) "coder-windows-amd64.asc" else "coder-windows-amd64.exe" - } - - val osName = when (os) { - OS.WINDOWS -> "windows" - OS.LINUX -> "linux" - OS.MAC -> "darwin" + val (osName, extension) = when (os) { + OS.WINDOWS -> "windows" to ".exe" + OS.LINUX -> "linux" to "" + OS.MAC -> "darwin" to "" + null -> { + logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") + return "coder-windows-amd64.exe" + } } val archName = when (arch) { @@ -295,14 +307,17 @@ class CoderSettingsStore( else -> "amd64" // default fallback } - val extension = if (isSignature) ".asc" else when (os) { - OS.WINDOWS -> ".exe" - OS.LINUX, OS.MAC -> "" - } - return "coder-$osName-$archName$extension" } + /** + * Return the name of the signature file (.asc) for the provided OS and architecture. + */ + private fun getCoderSignatureForOS(os: OS?, arch: Arch?): String { + logger.debug("Resolving signature for $os $arch") + return "${getCoderCLIForOS(os, arch)}.asc" + } + /** * Append the host to the path. For example, foo/bar could become * foo/bar/dev.coder.com-8080. diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 91e3b749..bc46c4fd 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -2,16 +2,22 @@ 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" +internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation" + internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" +internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel" + internal const val BINARY_NAME = "binaryName" internal const val DATA_DIRECTORY = "dataDirectory" @@ -42,3 +48,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 23b015dd..8e4dfbb3 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,34 +2,43 @@ 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.coder.toolbox.views.CoderCliSetupWizardPage +import com.coder.toolbox.views.CoderSettingsPage +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.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout import java.net.URI import java.util.UUID import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes 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 settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -44,8 +53,6 @@ open class CoderProtocolHandler( suspend fun handle( uri: URI, shouldWaitForAutoLogin: Boolean, - markAsBusy: () -> Unit, - unmarkAsBusy: () -> Unit, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() @@ -57,48 +64,73 @@ open class CoderProtocolHandler( // 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 = resolveToken(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 { + suspend fun onConnect( + restClient: CoderRestClient, + cli: CoderCLIManager + ) { + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) + if (workspace == null) { + context.envPageManager.showPluginEnvironmentsPage() + return + } reInitialize(restClient, cli) context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) 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() + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - launchIde(environmentId, productCode, buildNumber, projectFolder) + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } } + + CoderCliSetupContext.apply { + url = deploymentURL.toURL() + CoderCliSetupContext.token = token + } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + + // If Toolbox is already opened and URI is executed the setup page + // from below is never called. I tried a couple of things, including + // yielding the coroutine - but it seems to be of no help. What works + // delaying the coroutine for 66 - to 100 milliseconds, these numbers + // were determined by trial and error. + // The only explanation that I have is that inspecting the TBX bytecode it seems the + // UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events + // and a drop oldest strategy. For some reason it seems that the UI collector + // is not yet active, causing the event to be lost unless we wait > 66 ms. + // I think this delay ensures the collector is ready before processEvent() is called. + delay(100.milliseconds) + context.ui.showUiPage( + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, true, + jumpToMainPageOnError = true, + onConnect = ::onConnect + ) + ) } private suspend fun resolveDeploymentUrl(params: Map): String? { @@ -107,6 +139,11 @@ open class CoderProtocolHandler( 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 } @@ -128,29 +165,6 @@ 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(), - if (settings.requireTokenAuth) token else null, - PluginManager.pluginInfo.version - ) - client.initializeSession() - return client - } - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { @@ -166,6 +180,7 @@ open class CoderProtocolHandler( private suspend fun prepareWorkspace( workspace: Workspace, restClient: CoderRestClient, + cli: CoderCLIManager, workspaceName: String, deploymentURL: String ): Boolean { @@ -193,7 +208,7 @@ open class CoderProtocolHandler( if (workspace.outdated) { restClient.updateWorkspace(workspace) } else { - restClient.startWorkspace(workspace) + cli.startWorkspace(workspace.ownerName, workspace.name) } } catch (e: Exception) { context.logAndShowError( @@ -251,7 +266,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 @@ -300,35 +318,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() @@ -387,19 +383,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") @@ -449,12 +443,6 @@ open class CoderProtocolHandler( } } - -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) diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index dac816e6..97a5df96 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, diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index c1aaa812..7e2a8e35 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -1,11 +1,44 @@ package com.coder.toolbox.util +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.WebUrlValidationResult.Valid import java.net.IDN import java.net.URI import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.validateStrictWebUrl(): WebUrlValidationResult = try { + val uri = URI(this) + + when { + uri.isOpaque -> Invalid( + "The URL \"$this\" is invalid because it is not in the standard format. " + + "Please enter a full web address like \"https://example.com\"" + ) + + !uri.isAbsolute -> Invalid( + "The URL \"$this\" is missing a scheme (like https://). " + + "Please enter a full web address like \"https://example.com\"" + ) + uri.scheme?.lowercase() !in setOf("http", "https") -> + Invalid( + "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" + ) + uri.authority.isNullOrBlank() -> + Invalid( + "The URL \"$this\" does not include a valid website name. " + + "Please enter a full web address like \"https://example.com\"" + ) + else -> Valid + } +} catch (_: Exception) { + Invalid( + "The input \"$this\" is not a valid web address. " + + "Please enter a full web address like \"https://example.com\"" + ) +} + fun URL.withPath(path: String): URL = URL( this.protocol, this.host, @@ -30,3 +63,8 @@ fun URI.toQueryParameters(): Map = (this.query ?: "") parts[0] to "" } } + +sealed class WebUrlValidationResult { + object Valid : WebUrlValidationResult() + data class Invalid(val reason: String) : WebUrlValidationResult() +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 51152043..eca1179f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -3,43 +3,42 @@ 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, 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, + shouldAutoLogin = shouldAutoSetup, + jumpToMainPageOnError, + visibilityState, this::displaySteps, onConnect ) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) /** * Fields for this page, displayed in order. @@ -47,23 +46,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 +60,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 +77,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 +99,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 +116,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..a7ad70f0 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 @@ -43,27 +47,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 448a20fb..b74b2d8c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,11 +1,19 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC +import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY +import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS +import com.coder.toolbox.settings.HttpLoggingVerbosity.NONE import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.CheckboxField +import com.jetbrains.toolbox.api.ui.components.ComboBoxField +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 import kotlinx.coroutines.flow.MutableStateFlow @@ -20,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(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() @@ -33,11 +45,30 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< 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, + context.i18n.ptrl("Disable Coder CLI signature verification") + ) private val signatureFallbackStrategyField = CheckboxField( settings.fallbackOnCoderForSignatures.isAllowed(), context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + + private val httpLoggingField = ComboBoxField( + ComboBoxField.Label(context.i18n.ptrl("HTTP logging level:")), + settings.httpClientLogLevel, + listOf( + LabelledValue(context.i18n.ptrl("None"), NONE, listOf("" to "No logs")), + LabelledValue(context.i18n.ptrl("Basic"), BASIC, listOf("" to "Method, URL and status")), + LabelledValue(context.i18n.ptrl("Header"), HEADERS, listOf("" to " Basic + sanitized headers")), + LabelledValue(context.i18n.ptrl("Body"), BODY, listOf("" to "Headers + body content")), + ) + ) + private val enableBinaryDirectoryFallbackField = CheckboxField( settings.enableBinaryDirectoryFallback, @@ -65,14 +96,17 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< private val networkInfoDirField = TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General) - + private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( binarySourceField, enableDownloadsField, + useAppNameField, binaryDirectoryField, enableBinaryDirectoryFallbackField, + disableSignatureVerificationField, signatureFallbackStrategyField, + httpLoggingField, dataDirectoryField, headerCommandField, tlsCertPathField, @@ -89,12 +123,15 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< 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) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) context.settingsStore.updateCertPath(tlsCertPathField.contentState.value) @@ -106,7 +143,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< 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...") @@ -135,6 +172,9 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< enableDownloadsField.checkedState.update { settings.enableDownloads } + useAppNameField.checkedState.update { + settings.useAppNameAsTitle + } signatureFallbackStrategyField.checkedState.update { settings.fallbackOnCoderForSignatures.isAllowed() } @@ -182,5 +222,20 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< networkInfoDirField.contentState.update { settings.networkInfoDir } + + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { + disableSignatureVerificationField.checkedState.collect { state -> + signatureFallbackStrategyField.visibility.update { + // the fallback checkbox should not be visible + // if signature verification is disabled + !state + } + } + } + } + + 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 9964d0c9..b6d0bbba 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -7,15 +7,17 @@ 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.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.yield -import java.util.concurrent.CancellationException private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -25,17 +27,16 @@ 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, + 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 +44,19 @@ class ConnectStep( ) override fun onVisible() { + errorReporter.flush() errorField.textState.update { context.i18n.pnotr("") } - if (CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requireTokenAuth && 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 +64,33 @@ 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 (!CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting + val hostName = url.host + signInJob?.cancel() - signInJob = context.cs.launch { + signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { try { + context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, - CoderCliSetupContext.url!!, - CoderCliSetupContext.token!!, + url, + if (context.settingsStore.requireTokenAuth) 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,47 +98,70 @@ class ConnectStep( statusField.textState.update { (context.i18n.pnotr(progress)) } } // We only need to log in if we are using token-based auth. - if (client.token != null) { - statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) } + if (context.settingsStore.requireTokenAuth) { + logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - cli.login(client.token) + 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() - CoderCliSetupContext.reset() - CoderCliSetupWizardState.resetSteps() + context.logger.info("Connection setup done, initializing the workspace poller...") onConnect(client, cli) + + CoderCliSetupContext.reset() + CoderCliSetupWizardState.goToFirstStep() + 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() } } } + 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.requireTokenAuth) { + CoderCliSetupWizardState.goToPreviousStep() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } + } + override fun onNext(): Boolean { return false } 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.resetSteps() - context.secrets.rememberMe = false - } else { - CoderCliSetupWizardState.goToPreviousStep() - } + handleNavigation() } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 2a768643..27e53f97 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,10 +1,12 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.settings.SignatureFallbackStrategy +import com.coder.toolbox.util.WebUrlValidationResult.Invalid 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 @@ -12,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 @@ -24,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) @@ -39,7 +44,7 @@ class DeploymentUrlStep( override val panel: RowGroup get() { - if (context.settingsStore.fallbackOnCoderForSignatures == SignatureFallbackStrategy.NOT_CONFIGURED) { + if (!context.settingsStore.disableSignatureVerification) { return RowGroup( RowGroup.RowField(urlField), RowGroup.RowField(emptyLine), @@ -58,34 +63,34 @@ 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 { context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) - var url = urlField.textState.value + val url = urlField.textState.value if (url.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return false } - url = if (!url.startsWith("http://") && !url.startsWith("https://")) { - "https://$url" - } else { - url - } try { CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { - notify("URL is invalid", e) + errorReporter.report("URL is invalid", e) return false } - CoderCliSetupWizardState.goToNextStep() + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToNextStep() + } else { + CoderCliSetupWizardState.goToLastStep() + } return true } @@ -94,6 +99,10 @@ class DeploymentUrlStep( */ private fun validateRawUrl(url: String): URL { try { + val result = url.validateStrictWebUrl() + if (result is Invalid) { + throw MalformedURLException(result.reason) + } return url.toURL() } catch (e: Exception) { throw MalformedURLException(e.message) diff --git a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt index 020ed8a3..3353fe43 100644 --- a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt +++ b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt @@ -21,20 +21,51 @@ class EnvironmentView( private val workspace: Workspace, private val agent: WorkspaceAgent, ) : SshEnvironmentContentsView { - override suspend fun getConnectionInfo(): SshConnectionInfo = object : SshConnectionInfo { - /** - * The host name generated by the cli manager for this workspace. - */ - override val host: String = cli.getHostname(url, workspace, agent) - - /** - * The port is ignored by the Coder proxy command. - */ - override val port: Int = 22 - - /** - * The username is ignored by the Coder proxy command. - */ - override val userName: String? = null + override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(url, cli, workspace, agent) +} + +private class WorkspaceSshConnectionInfo( + url: URL, + cli: CoderCLIManager, + private val workspace: Workspace, + private val agent: WorkspaceAgent, +) : SshConnectionInfo { + /** + * The host name generated by the cli manager for this workspace. + */ + override val host: String = cli.getHostname(url, workspace, agent) + + /** + * The port is ignored by the Coder proxy command. + */ + override val port: Int = 22 + + /** + * The username is ignored by the Coder proxy command. + */ + override val userName: String? = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WorkspaceSshConnectionInfo + + if (port != other.port) return false + if (workspace.name != other.workspace.name) return false + if (agent.name != other.agent.name) return false + if (host != other.host) return false + + return true + } + + override fun hashCode(): Int { + var result = port + result = 31 * result + workspace.name.hashCode() + result = 31 * result + agent.name.hashCode() + result = 31 * result + host.hashCode() + return result } + + } \ No newline at end of file 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/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt index f1efca4a..92a08451 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt @@ -25,7 +25,11 @@ object CoderCliSetupWizardState { currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal - 1) % WizardStep.entries.size] } - fun resetSteps() { + fun goToLastStep() { + currentStep = WizardStep.CONNECT + } + + fun goToFirstStep() { currentStep = WizardStep.URL_REQUEST } } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index f1761059..16b6ed5a 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -164,4 +164,31 @@ msgid "Abort" msgstr "" msgid "Run anyway" +msgstr "" + +msgid "Disable Coder CLI signature verification" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Basic" +msgstr "" + +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/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index c42ead24..49314c55 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -225,7 +225,7 @@ class CoderRestClientTest { val client = CoderRestClient(context, URL(url), "token") assertEquals(user.username, runBlocking { client.me() }.username) - val tests = listOf("invalid", null) + val tests = listOf("invalid") tests.forEach { token -> val ex = assertFailsWith( @@ -238,6 +238,26 @@ class CoderRestClientTest { srv.stop(0) } + @Test + fun `exception is raised when token is required for authentication and token value is null or empty`() { + listOf("", null).forEach { token -> + val ex = + assertFailsWith( + exceptionClass = IllegalStateException::class, + block = { + runBlocking { + CoderRestClient( + context, + URI.create("https://coder.com").toURL(), + token + ).me() + } + }, + ) + assertEquals(ex.message, "Token is required for https://coder.com deployment") + } + } + @Test fun testGetsWorkspaces() { val tests = diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt index 5798524d..636ef611 100644 --- a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -35,15 +35,11 @@ class CoderSettingsStoreTest { @Test fun `Default CLI and signature for Windows AMD64`() = - assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.asc") + assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") @Test fun `Default CLI and signature for Windows ARM64`() = - assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.asc") - - @Test - fun `Default CLI and signature for Windows ARMV7`() = - assertBinaryAndSignature("Windows 10", "armv7l", "coder-windows-armv7.exe", "coder-windows-armv7.asc") + assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.exe.asc") @Test fun `Default CLI and signature for Linux AMD64`() = @@ -65,13 +61,9 @@ class CoderSettingsStoreTest { fun `Default CLI and signature for Mac ARM64`() = assertBinaryAndSignature("Mac OS X", "aarch64", "coder-darwin-arm64", "coder-darwin-arm64.asc") - @Test - fun `Default CLI and signature for Mac ARMV7`() = - assertBinaryAndSignature("Mac OS X", "armv7l", "coder-darwin-armv7", "coder-darwin-armv7.asc") - @Test fun `Default CLI and signature for unknown OS and Arch`() = - assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.asc") + assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") @Test fun `Default CLI and signature for unknown Arch fallback on Linux`() = 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..1a840616 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -5,9 +5,11 @@ import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.views.CoderSettingsPage 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.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings @@ -16,15 +18,30 @@ 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.channels.Channel 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(), @@ -43,131 +60,176 @@ internal class CoderProtocolHandlerTest { private val protocolHandler = CoderProtocolHandler( context, DialogUi(context), + CoderSettingsPage(context, Channel(Channel.CONFLATED), {}), + MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), MutableStateFlow(false) ) - 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 1db26c7e..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"), ) } @@ -60,4 +60,96 @@ internal class URLExtensionsTest { ) } } + + @Test + fun `valid http URL should return Valid`() { + val result = "http://coder.com".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `valid https URL with path and query should return Valid`() { + val result = "https://coder.com/bin/coder-linux-amd64?query=1".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `relative URL should return Invalid with appropriate message`() { + val url = "/bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"/bin/coder-linux-amd64\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `opaque URI like mailto should return Invalid`() { + val url = "mailto:user@coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"mailto:user@coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `unsupported scheme like ftp should return Invalid`() { + val url = "ftp://coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"ftp://coder.com\" must start with http:// or https://, not \"ftp\""), + result + ) + } + + @Test + fun `http URL with missing authority should return Invalid`() { + val url = "http:///bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http:///bin/coder-linux-amd64\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `malformed URI should return Invalid with parsing error message`() { + val url = "http://[invalid-uri]" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The input \"http://[invalid-uri]\" is not a valid web address. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without colon should return Invalid as URI is not absolute`() { + val url = "http//coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http//coder.com\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without double forward slashes should return Invalid because the URI is not hierarchical`() { + val url = "http:coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http:coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without a single forward slash should return Invalid because the URI does not have a hostname`() { + val url = "https:/coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"https:/coder.com\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), + result + ) + } } 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