Skip to content

Handle Gateway links #289

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 14 commits into from
Aug 18, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
96 changes: 83 additions & 13 deletions src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.coder.gateway.sdk.CoderCLIManager
import com.coder.gateway.sdk.CoderRestClient
import com.coder.gateway.sdk.ex.AuthenticationResponseException
import com.coder.gateway.sdk.toURL
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
import com.coder.gateway.sdk.v2.models.toAgentModels
import com.coder.gateway.sdk.withPath
import com.coder.gateway.services.CoderSettingsState
Expand Down Expand Up @@ -46,19 +47,47 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {

val (client, username) = authenticate(deploymentURL.toURL(), parameters[TOKEN])

// TODO: If these are missing we could launch the wizard.
val name = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing")
val agent = parameters[AGENT] ?: throw IllegalArgumentException("Query parameter \"$AGENT\" is missing")
// TODO: If the workspace is missing we could launch the wizard.
val workspaceName = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing")

val workspaces = client.workspaces()
val agents = workspaces.flatMap { it.toAgentModels() }
val workspace = agents.firstOrNull { it.name == "$name.$agent" }
?: throw IllegalArgumentException("The agent $agent does not exist on the workspace $name or the workspace is off")
val workspace = workspaces.firstOrNull{ it.name == workspaceName } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist")

when (workspace.latestBuild.status) {
WorkspaceStatus.PENDING, WorkspaceStatus.STARTING ->
// TODO: Wait for the workspace to turn on.
throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please wait then try again")
WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED,
WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED ->
// TODO: Turn on the workspace.
throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please start the workspace and try again")
WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, ->
throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; unable to connect")
WorkspaceStatus.RUNNING -> Unit // All is well
}

val agents = workspace.toAgentModels()
if (agents.isEmpty()) {
throw IllegalArgumentException("The workspace \"$workspaceName\" has no agents")
}

// TODO: Turn on the workspace if it is off then wait for the agent
// to be ready. Also, distinguish between whether the
// workspace is off or the agent does not exist in the error
// above instead of showing a combined error.
// If the agent is missing and the workspace has only one, use that.
val agent = if (!parameters[AGENT].isNullOrBlank())
agents.firstOrNull { it.name == "$workspaceName.${parameters[AGENT]}"}
else if (agents.size == 1) agents.first()
else null

if (agent == null) {
// TODO: Show a dropdown and ask for an agent.
throw IllegalArgumentException("Query parameter \"$AGENT\" is missing")
}

if (agent.agentStatus.pending()) {
// TODO: Wait for the agent to be ready.
throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; please wait then try again")
} else if (!agent.agentStatus.ready()) {
throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; unable to connect")
}

val cli = CoderCLIManager.ensureCLI(
deploymentURL.toURL(),
Expand All @@ -71,7 +100,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
cli.login(client.token)

indicator.text = "Configuring Coder CLI..."
cli.configSsh(agents)
cli.configSsh(workspaces.flatMap { it.toAgentModels() })

// TODO: Ask for these if missing. Maybe we can reuse the second
// step of the wizard? Could also be nice if we automatically used
Expand All @@ -86,15 +115,19 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
throw IllegalArgumentException("One of \"$IDE_PATH_ON_HOST\" or \"$IDE_DOWNLOAD_LINK\" is required")
}

// Check that both the domain and the redirected domain are
// whitelisted. If not, check with the user whether to proceed.
verifyDownloadLink(parameters, deploymentURL.toURL())

// TODO: Ask for the project path if missing and validate the path.
val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing")

parameters
.withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), workspace))
.withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), agent))
.withProjectPath(folder)
.withWebTerminalLink(client.url.withPath("/@$username/$workspace.name/terminal").toString())
.withConfigDirectory(cli.coderConfigPath.toString())
.withName(name)
.withName(workspaceName)
}
return null
}
Expand Down Expand Up @@ -125,6 +158,43 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
}
}

/**
* Check that the link is whitelisted. If not, confirm with the user.
*/
private fun verifyDownloadLink(parameters: Map<String, String>, deploymentURL: URL) {
val link = parameters[IDE_DOWNLOAD_LINK]
if (link.isNullOrBlank()) {
return // Nothing to verify
}

val url = try {
link.toURL()
} catch (ex: Exception) {
throw IllegalArgumentException("$link is not a valid URL")
}

val (whitelisted, https, linkWithRedirect) = try {
CoderRemoteConnectionHandle.isWhitelisted(url, deploymentURL)
} catch (e: Exception) {
throw IllegalArgumentException("Unable to verify $url: $e")
}
if (whitelisted && https) {
return
}

val comment = if (whitelisted) "The download link is from a non-whitelisted URL"
else if (https) "The download link is not using HTTPS"
else "The download link is from a non-whitelisted URL and is not using HTTPS"

if (!CoderRemoteConnectionHandle.confirm(
"Confirm download URL",
"$comment. Would you like to proceed?",
linkWithRedirect,
)) {
throw IllegalArgumentException("$linkWithRedirect is not whitelisted")
}
}

override fun isApplicable(parameters: Map<String, String>): Boolean {
return parameters.areCoderType()
}
Expand Down
115 changes: 107 additions & 8 deletions src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ import kotlinx.coroutines.launch
import net.schmizz.sshj.common.SSHException
import net.schmizz.sshj.connection.ConnectionException
import java.awt.Dimension
import java.net.HttpURLConnection
import java.net.URL
import java.time.Duration
import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException

// CoderRemoteConnection uses the provided workspace SSH parameters to launch an
// IDE against the workspace. If successful the connection is added to recent
Expand Down Expand Up @@ -105,6 +107,33 @@ class CoderRemoteConnectionHandle {
companion object {
val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName)

/**
* Generic function to ask for consent.
*/
fun confirm(title: String, comment: String, details: String): Boolean {
var inputFromUser = false
ApplicationManager.getApplication().invokeAndWait({
val panel = panel {
row {
label(comment)
}
row {
label(details)
}
}
AppIcon.getInstance().requestAttention(null, true)
if (!dialog(
title = title,
panel = panel,
).showAndGet()
) {
return@invokeAndWait
}
inputFromUser = true
}, ModalityState.defaultModalityState())
return inputFromUser
}

/**
* Generic function to ask for input.
*/
Expand Down Expand Up @@ -166,16 +195,25 @@ class CoderRemoteConnectionHandle {
): Pair<String, TokenSource>? {
var (existingToken, tokenSource) = token ?: Pair("", TokenSource.USER)
val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth")
if (!isRetry && !useExisting) {
BrowserUtil.browse(getTokenUrl)
} else if (!isRetry && useExisting) {
val (u, t) = CoderCLIManager.readConfig()
if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) {
logger.info("Injecting token for $url from CLI config")
tokenSource = TokenSource.CONFIG
existingToken = t

// On the first run either open a browser to generate a new token
// or, if using an existing token, use the token on disk if it
// exists otherwise assume the user already copied an existing
// token and they will paste in.
if (!isRetry) {
if (!useExisting) {
BrowserUtil.browse(getTokenUrl)
} else {
val (u, t) = CoderCLIManager.readConfig()
if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) {
logger.info("Injecting token for $url from CLI config")
return Pair(t, TokenSource.CONFIG)
}
}
}

// On subsequent tries or if not using an existing token, ask the user
// for the token.
val tokenFromUser = ask(
CoderGatewayBundle.message(
if (isRetry) "gateway.connector.view.workspaces.token.rejected"
Expand All @@ -200,5 +238,66 @@ class CoderRemoteConnectionHandle {
}
return Pair(tokenFromUser, tokenSource)
}

/**
* Return if the URL is whitelisted, https, and the URL and its final
* destination, if it is a different host.
*/
@JvmStatic
fun isWhitelisted(url: URL, deploymentURL: URL): Triple<Boolean, Boolean, String> {
// TODO: Setting for the whitelist, and remember previously allowed
// domains.
val domainWhitelist = listOf("intellij.net", "jetbrains.com", deploymentURL.host)

// Resolve any redirects.
val finalUrl = try {
resolveRedirects(url)
} catch (e: Exception) {
when (e) {
is SSLHandshakeException ->
throw Exception(CoderGatewayBundle.message(
"gateway.connector.view.workspaces.connect.ssl-error",
url.host,
e.message ?: CoderGatewayBundle.message("gateway.connector.view.workspaces.connect.no-reason")
))
else -> throw e
Comment on lines +257 to +263
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may cause some headaches if the required certs are not available in the JRE keystore. I think it should be fine if it's trusted by the system though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my testing (although I might have missed something), adding to the system trust store was not enough, it had to be in the JRE key store that was bundled with Gateway.

The help text has a link to the docs that explains how to add the cert, but I do think it would be nice as a future enhancement to give the option to view and accept the cert. Not sure how difficult that would be.

}
}

var linkWithRedirect = url.toString()
if (finalUrl.host != url.host) {
linkWithRedirect = "$linkWithRedirect (redirects to to $finalUrl)"
}

val whitelisted = domainWhitelist.any { url.host == it || url.host.endsWith(".$it") }
&& domainWhitelist.any { finalUrl.host == it || finalUrl.host.endsWith(".$it") }
val https = url.protocol == "https" && finalUrl.protocol == "https"
return Triple(whitelisted, https, linkWithRedirect)
}

/**
* Follow a URL's redirects to its final destination.
*/
@JvmStatic
fun resolveRedirects(url: URL): URL {
var location = url
val maxRedirects = 10
for (i in 1..maxRedirects) {
val conn = location.openConnection() as HttpURLConnection
conn.instanceFollowRedirects = false
conn.connect()
val code = conn.responseCode
val nextLocation = conn.getHeaderField("Location");
conn.disconnect()
// Redirects are triggered by any code starting with 3 plus a
// location header.
if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) {
return location
}
// Location headers might be relative.
location = URL(location, nextLocation)
}
throw Exception("Too many redirects")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ enum class WorkspaceAndAgentStatus(val icon: Icon, val label: String, val descri
.contains(this)
}

/**
* Return true if the agent might soon be in a connectable state.
*/
fun pending(): Boolean {
return listOf(CONNECTING, TIMEOUT, CREATED, AGENT_STARTING, START_TIMEOUT)
.contains(this)
}

// We want to check that the workspace is `running`, the agent is
// `connected`, and the agent lifecycle state is `ready` to ensure the best
// possible scenario for attempting a connection.
Expand Down
14 changes: 7 additions & 7 deletions src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class CoderRestClient(var url: URL, var token: String) {
fun me(): User {
val userResponse = retroRestClient.me().execute()
if (!userResponse.isSuccessful) {
throw AuthenticationResponseException("Authentication to $url failed with code ${userResponse.code()}, ${userResponse.message().ifBlank { "has your token expired?" }}")
throw AuthenticationResponseException("Unable to authenticate to $url: code ${userResponse.code()}, ${userResponse.message().ifBlank { "has your token expired?" }}")
}

return userResponse.body()!!
Expand All @@ -88,7 +88,7 @@ class CoderRestClient(var url: URL, var token: String) {
fun workspaces(): List<Workspace> {
val workspacesResponse = retroRestClient.workspaces("owner:me").execute()
if (!workspacesResponse.isSuccessful) {
throw WorkspaceResponseException("Retrieving workspaces from $url failed with code ${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}")
throw WorkspaceResponseException("Unable to retrieve workspaces from $url: code ${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}")
}

return workspacesResponse.body()!!.workspaces
Expand All @@ -97,15 +97,15 @@ class CoderRestClient(var url: URL, var token: String) {
fun buildInfo(): BuildInfo {
val buildInfoResponse = retroRestClient.buildInfo().execute()
if (!buildInfoResponse.isSuccessful) {
throw java.lang.IllegalStateException("Retrieving build information for $url failed with code ${buildInfoResponse.code()}, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}")
throw java.lang.IllegalStateException("Unable to retrieve build information for $url, code: ${buildInfoResponse.code()}, reason: ${buildInfoResponse.message().ifBlank { "no reason provided" }}")
}
return buildInfoResponse.body()!!
}

private fun template(templateID: UUID): Template {
val templateResponse = retroRestClient.template(templateID).execute()
if (!templateResponse.isSuccessful) {
throw TemplateResponseException("Retrieving template with id $templateID from $url failed with code ${templateResponse.code()}, reason: ${templateResponse.message().ifBlank { "no reason provided" }}")
throw TemplateResponseException("Unable to retrieve template with ID $templateID from $url, code: ${templateResponse.code()}, reason: ${templateResponse.message().ifBlank { "no reason provided" }}")
}
return templateResponse.body()!!
}
Expand All @@ -114,7 +114,7 @@ class CoderRestClient(var url: URL, var token: String) {
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null)
val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute()
if (buildResponse.code() != HTTP_CREATED) {
throw WorkspaceResponseException("Building workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
throw WorkspaceResponseException("Unable to build workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
}

return buildResponse.body()!!
Expand All @@ -124,7 +124,7 @@ class CoderRestClient(var url: URL, var token: String) {
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null)
val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute()
if (buildResponse.code() != HTTP_CREATED) {
throw WorkspaceResponseException("Stopping workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
throw WorkspaceResponseException("Unable to stop workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
}

return buildResponse.body()!!
Expand All @@ -136,7 +136,7 @@ class CoderRestClient(var url: URL, var token: String) {
val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null)
val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute()
if (buildResponse.code() != HTTP_CREATED) {
throw WorkspaceResponseException("Updating workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
throw WorkspaceResponseException("Unable to update workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
}

return buildResponse.body()!!
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/messages/CoderGatewayBundle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ gateway.connector.settings.data-directory.comment=Directories are created \
Defaults to {0}.
gateway.connector.settings.binary-source.title=CLI source:
gateway.connector.settings.binary-source.comment=Used to download the Coder \
CLI which is necessary to make SSH connections. The If-None-Matched header \
CLI which is necessary to make SSH connections. The If-None-Match header \
will be set to the SHA1 of the CLI and can be used for caching. Absolute \
URLs will be used as-is; otherwise this value will be resolved against the \
deployment domain. \
Expand Down
Loading