Skip to content

impl: verify cli signature #562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

## Unreleased

### Added

- support for checking if CLI is signed
- improved progress reporting while downloading the CLI

## 2.21.1 - 2025-06-26

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ dependencies {
testImplementation(kotlin("test"))
// required by the unit tests
testImplementation(kotlin("test-junit5"))
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// required by IntelliJ test framework
testImplementation("junit:junit:4.13.2")

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway
artifactName=coder-gateway
pluginName=Coder
# SemVer format -> https://semver.org
pluginVersion=2.21.1
pluginVersion=2.22.0
# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
# for insight into build numbers and IntelliJ Platform versions.
pluginSinceBuild=243.26574
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm")
private val dialogUi = DialogUi(settings)

fun connect(getParameters: (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
fun connect(getParameters: suspend (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
val clientLifetime = LifetimeDefinition()
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title")) {
try {
Expand Down Expand Up @@ -274,7 +274,7 @@
},
),
)
val handle = client.connect(URI(joinLink)) // Downloads the client too, if needed.

Check warning on line 277 in src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Usage of redundant or deprecated syntax or deprecated symbols

'connect(URI): ThinClientHandle' is deprecated. Use connect(URI, ExtractedJetBrainsClientData)

// Reconnect if the join link changes.
logger.info("Launched ${workspace.ideName} client; beginning backend monitoring")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"),
)
}.layout(RowLayout.PARENT_GRID)
row {
cell() // For alignment.
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
.bindSelected(state::fallbackOnCoderForSignatures)
.comment(
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
)
}.layout(RowLayout.PARENT_GRID)
row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) {
textField().resizableColumn().align(AlignX.FILL)
.bindText(state::headerCommand)
Expand Down
239 changes: 159 additions & 80 deletions src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.coder.gateway.cli.downloader

import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.HeaderMap
import retrofit2.http.Streaming
import retrofit2.http.Url

/**
* Retrofit API for downloading CLI
*/
interface CoderDownloadApi {
@GET
@Streaming
suspend fun downloadCli(
@Url url: String,
@Header("If-None-Match") eTag: String? = null,
@HeaderMap headers: Map<String, String> = emptyMap(),
@Header("Accept-Encoding") acceptEncoding: String = "gzip",
): Response<ResponseBody>

@GET
suspend fun downloadSignature(
@Url url: String,
@HeaderMap headers: Map<String, String> = emptyMap()
): Response<ResponseBody>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package com.coder.gateway.cli.downloader

import com.coder.gateway.cli.ex.ResponseException
import com.coder.gateway.settings.CoderSettings
import com.coder.gateway.util.OS
import com.coder.gateway.util.SemVer
import com.coder.gateway.util.getHeaders
import com.coder.gateway.util.getOS
import com.coder.gateway.util.sha1
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import retrofit2.Response
import java.io.FileInputStream
import java.net.HttpURLConnection.HTTP_NOT_FOUND
import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
import java.net.HttpURLConnection.HTTP_OK
import java.net.URI
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.util.zip.GZIPInputStream
import kotlin.io.path.name
import kotlin.io.path.notExists

/**
* Handles the download steps of Coder CLI
*/
class CoderDownloadService(
private val settings: CoderSettings,
private val downloadApi: CoderDownloadApi,
private val deploymentUrl: URL,
forceDownloadToData: Boolean,
) {
private val remoteBinaryURL: URL = settings.binSource(deploymentUrl)
private val cliFinalDst: Path = settings.binPath(deploymentUrl, forceDownloadToData)
private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp")

suspend fun downloadCli(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): DownloadResult {
val eTag = calculateLocalETag()
if (eTag != null) {
logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag")
}
val response = downloadApi.downloadCli(
url = remoteBinaryURL.toString(),
eTag = eTag?.let { "\"$it\"" },
headers = getRequestHeaders()
)

return when (response.code()) {
HTTP_OK -> {
logger.info("Downloading binary to temporary $cliTempDst")
response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable()
DownloadResult.Downloaded(remoteBinaryURL, cliTempDst)
}

HTTP_NOT_MODIFIED -> {
logger.info("Using cached binary at $cliFinalDst")
showTextProgress?.invoke("Using cached binary")
DownloadResult.Skipped
}

else -> {
throw ResponseException(
"Unexpected response from $remoteBinaryURL",
response.code()
)
}
}
}

/**
* Renames the temporary binary file to its original destination name.
* The implementation will override sibling file that has the original
* destination name.
*/
suspend fun commit(): Path {
return withContext(Dispatchers.IO) {
logger.info("Renaming binary from $cliTempDst to $cliFinalDst")
Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING)
cliFinalDst.makeExecutable()
cliFinalDst
}
}

/**
* Cleans up the temporary binary file if it exists.
*/
suspend fun cleanup() {
withContext(Dispatchers.IO) {
runCatching { Files.deleteIfExists(cliTempDst) }
.onFailure { ex ->
logger.warn("Failed to delete temporary CLI file: $cliTempDst", ex)
}
}
}

private fun calculateLocalETag(): String? {
return try {
if (cliFinalDst.notExists()) {
return null
}
sha1(FileInputStream(cliFinalDst.toFile()))
} catch (e: Exception) {
logger.warn("Unable to calculate hash for $cliFinalDst", e)
null
}
}

private fun getRequestHeaders(): Map<String, String> {
return if (settings.headerCommand.isBlank()) {
emptyMap()
} else {
getHeaders(deploymentUrl, settings.headerCommand)
}
}

private fun Response<ResponseBody>.saveToDisk(
localPath: Path,
showTextProgress: ((t: String) -> Unit)? = null,
buildVersion: String? = null
): Path? {
val responseBody = this.body() ?: return null
Files.deleteIfExists(localPath)
Files.createDirectories(localPath.parent)

val outputStream = Files.newOutputStream(
localPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)
val contentEncoding = this.headers()["Content-Encoding"]
val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) {
GZIPInputStream(responseBody.byteStream())
} else {
responseBody.byteStream()
}

val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
var totalRead = 0L
// local path is a temporary filename, reporting the progress with the real name
val binaryName = localPath.name.removeSuffix(".tmp")
sourceStream.use { source ->
outputStream.use { sink ->
while (source.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
totalRead += bytesRead
val prettyBuildVersion = buildVersion ?: ""
showTextProgress?.invoke(
"$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded"
)
}
}
}
return cliFinalDst
}


private fun Path.makeExecutable() {
if (getOS() != OS.WINDOWS) {
logger.info("Making $this executable...")
this.toFile().setExecutable(true)
}
}

private fun Long.toHumanReadableSize(): String {
if (this < 1024) return "$this B"

val kb = this / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)

val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)

val gb = mb / 1024.0
return String.format("%.1f GB", gb)
}

suspend fun downloadSignature(showTextProgress: ((t: String) -> Unit)? = null): DownloadResult {
return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders())
}

private suspend fun downloadSignature(
url: URL,
showTextProgress: ((t: String) -> Unit)? = null,
headers: Map<String, String> = emptyMap()
): DownloadResult {
val signatureURL = url.toURI().resolve(settings.defaultSignatureNameByOsAndArch).toURL()
val localSignaturePath = cliFinalDst.parent.resolve(settings.defaultSignatureNameByOsAndArch)
logger.info("Downloading signature from $signatureURL")

val response = downloadApi.downloadSignature(
url = signatureURL.toString(),
headers = headers
)

return when (response.code()) {
HTTP_OK -> {
response.saveToDisk(localSignaturePath, showTextProgress)
DownloadResult.Downloaded(signatureURL, localSignaturePath)
}

HTTP_NOT_FOUND -> {
logger.warn("Signature file not found at $signatureURL")
DownloadResult.NotFound
}

else -> {
DownloadResult.Failed(
ResponseException(
"Failed to download signature from $signatureURL",
response.code()
)
)
}
}

}

suspend fun downloadReleasesSignature(
buildVersion: String,
showTextProgress: ((t: String) -> Unit)? = null
): DownloadResult {
val semVer = SemVer.parse(buildVersion)
return downloadSignature(
URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(),
showTextProgress
)
}

companion object {
val logger = Logger.getInstance(CoderDownloadService::class.java.simpleName)
}
}
23 changes: 23 additions & 0 deletions src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.coder.gateway.cli.downloader

import java.net.URL
import java.nio.file.Path


/**
* Result of a download operation
*/
sealed class DownloadResult {
object Skipped : DownloadResult()

Check notice on line 11 in src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
object NotFound : DownloadResult()

Check notice on line 12 in src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
data class Downloaded(val source: URL, val dst: Path) : DownloadResult()
data class Failed(val error: Exception) : DownloadResult()

fun isSkipped(): Boolean = this is Skipped

fun isNotFound(): Boolean = this is NotFound

fun isFailed(): Boolean = this is Failed

fun isNotDownloaded(): Boolean = this !is Downloaded
}
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message)
class SSHConfigFormatException(message: String) : Exception(message)

class MissingVersionException(message: String) : Exception(message)

class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message)
Loading
Loading
pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy