Skip to content

Commit a7e08c2

Browse files
authored
Handle Gateway links (#289)
* Break connection code out into separate class The connect entrypoint is used both for Gateway links (jetbrains-gateway://) and internally in our own code at the end of the wizard and for recent connections, but this will be two separate sets of parameters (internally we have the CLI set up and pass around the hostname while the links will only have the workspace ID or name). So break out the code into a separate class that we can call internally which will let us dedicate the connect entrypoint to handle the Gateway links. There we will set up the CLI and gather the required parameters before calling the now-broken-out code. * Break out askToken dialog So it can be reused in the link flow. * Add generic function to ask for input * Add QUERY token source and show deployment host When you connect via a Gateway link it might not be obvious if the deployment URL is wrong. * Tweak client error responses * Use exception class name when there is no message Some exceptions have no message, like null pointer exceptions. Showing the class name seems more helpful than "no details", since it can save you a trip to the logs. * Handle Gateway links * Fix If-None-Match header name * Automatically use token on disk if there is one Rather than asking the user to confirm. This only happens if we explicitly want to use an existing token anyway, and "existing" is defined in the help text as either the token on disk or one the user already copied, so the extra confirmation to use the token on disk seems unnecessary. * Confirm download link if not whitelisted
1 parent 24967e7 commit a7e08c2

12 files changed

+601
-186
lines changed

README.md

+10
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ To manually install a local build:
3939

4040
Alternatively, `./gradlew clean runIde` will deploy a Gateway distribution (the one specified in `gradle.properties` - `platformVersion`) with the latest plugin changes deployed.
4141

42+
To simulate opening a workspace from the dashboard pass the Gateway link via `--args`. For example:
43+
44+
```
45+
./gradlew clean runIDE --args="jetbrains-gateway://connect#type=coder&workspace=dev&agent=coder&folder=/home/coder&url=https://dev.coder.com&token=<redacted>&ide_product_code=IU&ide_build_number=223.8836.41&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2022.3.3.tar.gz"
46+
```
47+
48+
Alternatively, if you have separately built the plugin and already installed it
49+
in a Gateway distribution you can launch that distribution with the URL as the
50+
first argument (no `--args` in this case).
51+
4252
### Plugin Structure
4353

4454
```

src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt

+179-73
Original file line numberDiff line numberDiff line change
@@ -2,93 +2,199 @@
22

33
package com.coder.gateway
44

5-
import com.coder.gateway.sdk.humanizeDuration
6-
import com.coder.gateway.sdk.isCancellation
7-
import com.coder.gateway.sdk.isWorkerTimeout
8-
import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff
9-
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
10-
import com.intellij.openapi.application.ApplicationManager
5+
import com.coder.gateway.models.TokenSource
6+
import com.coder.gateway.sdk.CoderCLIManager
7+
import com.coder.gateway.sdk.CoderRestClient
8+
import com.coder.gateway.sdk.ex.AuthenticationResponseException
9+
import com.coder.gateway.sdk.toURL
10+
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
11+
import com.coder.gateway.sdk.v2.models.toAgentModels
12+
import com.coder.gateway.sdk.withPath
13+
import com.coder.gateway.services.CoderSettingsState
1114
import com.intellij.openapi.components.service
1215
import com.intellij.openapi.diagnostic.Logger
13-
import com.intellij.openapi.rd.util.launchUnderBackgroundProgress
14-
import com.intellij.openapi.ui.Messages
1516
import com.jetbrains.gateway.api.ConnectionRequestor
1617
import com.jetbrains.gateway.api.GatewayConnectionHandle
1718
import com.jetbrains.gateway.api.GatewayConnectionProvider
18-
import com.jetbrains.gateway.api.GatewayUI
19-
import com.jetbrains.gateway.ssh.SshDeployFlowUtil
20-
import com.jetbrains.gateway.ssh.SshMultistagePanelContext
21-
import com.jetbrains.gateway.ssh.deploy.DeployException
22-
import com.jetbrains.rd.util.lifetime.LifetimeDefinition
23-
import kotlinx.coroutines.launch
24-
import net.schmizz.sshj.common.SSHException
25-
import net.schmizz.sshj.connection.ConnectionException
26-
import java.time.Duration
27-
import java.util.concurrent.TimeoutException
19+
import java.net.URL
2820

21+
// In addition to `type`, these are the keys that we support in our Gateway
22+
// links.
23+
private const val URL = "url"
24+
private const val TOKEN = "token"
25+
private const val WORKSPACE = "workspace"
26+
private const val AGENT = "agent"
27+
private const val FOLDER = "folder"
28+
private const val IDE_DOWNLOAD_LINK = "ide_download_link"
29+
private const val IDE_PRODUCT_CODE = "ide_product_code"
30+
private const val IDE_BUILD_NUMBER = "ide_build_number"
31+
private const val IDE_PATH_ON_HOST = "ide_path_on_host"
32+
33+
// CoderGatewayConnectionProvider handles connecting via a Gateway link such as
34+
// jetbrains-gateway://connect#type=coder.
2935
class CoderGatewayConnectionProvider : GatewayConnectionProvider {
30-
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
36+
private val settings: CoderSettingsState = service()
3137

3238
override suspend fun connect(parameters: Map<String, String>, requestor: ConnectionRequestor): GatewayConnectionHandle? {
33-
val clientLifetime = LifetimeDefinition()
34-
// TODO: If this fails determine if it is an auth error and if so prompt
35-
// for a new token, configure the CLI, then try again.
36-
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) {
37-
try {
38-
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting")
39-
val context = suspendingRetryWithExponentialBackOff(
40-
action = { attempt ->
41-
logger.info("Connecting... (attempt $attempt")
42-
if (attempt > 1) {
43-
// indicator.text is the text above the progress bar.
44-
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt)
45-
}
46-
SshMultistagePanelContext(parameters.toHostDeployInputs())
47-
},
48-
retryIf = {
49-
it is ConnectionException || it is TimeoutException
50-
|| it is SSHException || it is DeployException
51-
},
52-
onException = { attempt, nextMs, e ->
53-
logger.error("Failed to connect (attempt $attempt; will retry in $nextMs ms)")
54-
// indicator.text2 is the text below the progress bar.
55-
indicator.text2 =
56-
if (isWorkerTimeout(e)) "Failed to upload worker binary...it may have timed out"
57-
else e.message ?: CoderGatewayBundle.message("gateway.connector.no-details")
58-
},
59-
onCountdown = { remainingMs ->
60-
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.failed.retry", humanizeDuration(remainingMs))
61-
},
62-
)
63-
launch {
64-
logger.info("Deploying and starting IDE with $context")
65-
// At this point JetBrains takes over with their own UI.
66-
@Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle(
67-
clientLifetime, context, Duration.ofMinutes(10)
68-
)
69-
}
70-
} catch (e: Exception) {
71-
if (isCancellation(e)) {
72-
logger.info("Connection canceled due to ${e.javaClass}")
73-
} else {
74-
logger.info("Failed to connect (will not retry)", e)
75-
// The dialog will close once we return so write the error
76-
// out into a new dialog.
77-
ApplicationManager.getApplication().invokeAndWait {
78-
Messages.showMessageDialog(
79-
e.message ?: CoderGatewayBundle.message("gateway.connector.no-details"),
80-
CoderGatewayBundle.message("gateway.connector.coder.connection.failed"),
81-
Messages.getErrorIcon())
82-
}
83-
}
39+
CoderRemoteConnectionHandle().connect{ indicator ->
40+
logger.debug("Launched Coder connection provider", parameters)
41+
42+
val deploymentURL = parameters[URL]
43+
?: CoderRemoteConnectionHandle.ask("Enter the full URL of your Coder deployment")
44+
if (deploymentURL.isNullOrBlank()) {
45+
throw IllegalArgumentException("Query parameter \"$URL\" is missing")
8446
}
85-
}
8647

87-
recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection())
88-
GatewayUI.getInstance().reset()
48+
val (client, username) = authenticate(deploymentURL.toURL(), parameters[TOKEN])
49+
50+
// TODO: If the workspace is missing we could launch the wizard.
51+
val workspaceName = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing")
52+
53+
val workspaces = client.workspaces()
54+
val workspace = workspaces.firstOrNull{ it.name == workspaceName } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist")
55+
56+
when (workspace.latestBuild.status) {
57+
WorkspaceStatus.PENDING, WorkspaceStatus.STARTING ->
58+
// TODO: Wait for the workspace to turn on.
59+
throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please wait then try again")
60+
WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED,
61+
WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED ->
62+
// TODO: Turn on the workspace.
63+
throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please start the workspace and try again")
64+
WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, ->
65+
throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; unable to connect")
66+
WorkspaceStatus.RUNNING -> Unit // All is well
67+
}
68+
69+
val agents = workspace.toAgentModels()
70+
if (agents.isEmpty()) {
71+
throw IllegalArgumentException("The workspace \"$workspaceName\" has no agents")
72+
}
73+
74+
// If the agent is missing and the workspace has only one, use that.
75+
val agent = if (!parameters[AGENT].isNullOrBlank())
76+
agents.firstOrNull { it.name == "$workspaceName.${parameters[AGENT]}"}
77+
else if (agents.size == 1) agents.first()
78+
else null
79+
80+
if (agent == null) {
81+
// TODO: Show a dropdown and ask for an agent.
82+
throw IllegalArgumentException("Query parameter \"$AGENT\" is missing")
83+
}
84+
85+
if (agent.agentStatus.pending()) {
86+
// TODO: Wait for the agent to be ready.
87+
throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; please wait then try again")
88+
} else if (!agent.agentStatus.ready()) {
89+
throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; unable to connect")
90+
}
91+
92+
val cli = CoderCLIManager.ensureCLI(
93+
deploymentURL.toURL(),
94+
client.buildInfo().version,
95+
settings,
96+
indicator,
97+
)
98+
99+
indicator.text = "Authenticating Coder CLI..."
100+
cli.login(client.token)
101+
102+
indicator.text = "Configuring Coder CLI..."
103+
cli.configSsh(workspaces.flatMap { it.toAgentModels() })
104+
105+
// TODO: Ask for these if missing. Maybe we can reuse the second
106+
// step of the wizard? Could also be nice if we automatically used
107+
// the last IDE.
108+
if (parameters[IDE_PRODUCT_CODE].isNullOrBlank()) {
109+
throw IllegalArgumentException("Query parameter \"$IDE_PRODUCT_CODE\" is missing")
110+
}
111+
if (parameters[IDE_BUILD_NUMBER].isNullOrBlank()) {
112+
throw IllegalArgumentException("Query parameter \"$IDE_BUILD_NUMBER\" is missing")
113+
}
114+
if (parameters[IDE_PATH_ON_HOST].isNullOrBlank() && parameters[IDE_DOWNLOAD_LINK].isNullOrBlank()) {
115+
throw IllegalArgumentException("One of \"$IDE_PATH_ON_HOST\" or \"$IDE_DOWNLOAD_LINK\" is required")
116+
}
117+
118+
// Check that both the domain and the redirected domain are
119+
// allowlisted. If not, check with the user whether to proceed.
120+
verifyDownloadLink(parameters)
121+
122+
// TODO: Ask for the project path if missing and validate the path.
123+
val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing")
124+
125+
parameters
126+
.withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), agent))
127+
.withProjectPath(folder)
128+
.withWebTerminalLink(client.url.withPath("/@$username/$workspace.name/terminal").toString())
129+
.withConfigDirectory(cli.coderConfigPath.toString())
130+
.withName(workspaceName)
131+
}
89132
return null
90133
}
91134

135+
/**
136+
* Return an authenticated Coder CLI and the user's name, asking for the
137+
* token as long as it continues to result in an authentication failure.
138+
*/
139+
private fun authenticate(deploymentURL: URL, queryToken: String?, lastToken: Pair<String, TokenSource>? = null): Pair<CoderRestClient, String> {
140+
// Use the token from the query, unless we already tried that.
141+
val isRetry = lastToken != null
142+
val token = if (!queryToken.isNullOrBlank() && !isRetry)
143+
Pair(queryToken, TokenSource.QUERY)
144+
else CoderRemoteConnectionHandle.askToken(
145+
deploymentURL,
146+
lastToken,
147+
isRetry,
148+
useExisting = true,
149+
)
150+
if (token == null) { // User aborted.
151+
throw IllegalArgumentException("Unable to connect to $deploymentURL, $TOKEN is missing")
152+
}
153+
val client = CoderRestClient(deploymentURL, token.first)
154+
return try {
155+
Pair(client, client.me().username)
156+
} catch (ex: AuthenticationResponseException) {
157+
authenticate(deploymentURL, queryToken, token)
158+
}
159+
}
160+
161+
/**
162+
* Check that the link is allowlisted. If not, confirm with the user.
163+
*/
164+
private fun verifyDownloadLink(parameters: Map<String, String>) {
165+
val link = parameters[IDE_DOWNLOAD_LINK]
166+
if (link.isNullOrBlank()) {
167+
return // Nothing to verify
168+
}
169+
170+
val url = try {
171+
link.toURL()
172+
} catch (ex: Exception) {
173+
throw IllegalArgumentException("$link is not a valid URL")
174+
}
175+
176+
val (allowlisted, https, linkWithRedirect) = try {
177+
CoderRemoteConnectionHandle.isAllowlisted(url)
178+
} catch (e: Exception) {
179+
throw IllegalArgumentException("Unable to verify $url: $e")
180+
}
181+
if (allowlisted && https) {
182+
return
183+
}
184+
185+
val comment = if (allowlisted) "The download link is from a non-allowlisted URL"
186+
else if (https) "The download link is not using HTTPS"
187+
else "The download link is from a non-allowlisted URL and is not using HTTPS"
188+
189+
if (!CoderRemoteConnectionHandle.confirm(
190+
"Confirm download URL",
191+
"$comment. Would you like to proceed?",
192+
linkWithRedirect,
193+
)) {
194+
throw IllegalArgumentException("$linkWithRedirect is not allowlisted")
195+
}
196+
}
197+
92198
override fun isApplicable(parameters: Map<String, String>): Boolean {
93199
return parameters.areCoderType()
94200
}

0 commit comments

Comments
 (0)