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
Next Next commit
impl: support for downloading and verifying cli signatures
  • Loading branch information
fioan89 committed Jul 22, 2025
commit 4001f7d22f254df047fd9322fcdfc5262c58f08e
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
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class CoderRemoteConnectionHandle {
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
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()
object NotFound : DownloadResult()
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