@@ -13,7 +13,9 @@ import com.intellij.openapi.components.service
1313import com.intellij.openapi.diagnostic.thisLogger
1414import com.intellij.openapi.progress.ProgressManager
1515import com.intellij.openapi.ui.Messages
16+ import com.intellij.remote.AuthType
1617import com.intellij.remote.RemoteCredentialsHolder
18+ import com.intellij.remoteDev.util.onTerminationOrNow
1719import com.intellij.ssh.AskAboutHostKey
1820import com.intellij.ssh.OpenSshLikeHostKeyVerifier
1921import com.intellij.ssh.connectionBuilder
@@ -24,6 +26,10 @@ import com.intellij.ui.dsl.gridLayout.HorizontalAlign
2426import com.intellij.ui.dsl.gridLayout.VerticalAlign
2527import com.intellij.util.application
2628import com.intellij.util.io.DigestUtil
29+ import com.intellij.util.io.await
30+ import com.intellij.util.io.delete
31+ import com.intellij.util.net.ssl.CertificateManager
32+ import com.intellij.util.proxy.CommonProxy
2733import com.intellij.util.ui.JBFont
2834import com.intellij.util.ui.JBUI
2935import com.intellij.util.ui.UIUtil
@@ -39,7 +45,6 @@ import com.jetbrains.rd.util.lifetime.LifetimeDefinition
3945import io.gitpod.gitpodprotocol.api.entities.WorkspaceInstance
4046import io.gitpod.jetbrains.icons.GitpodIcons
4147import kotlinx.coroutines.*
42- import kotlinx.coroutines.future.await
4348import java.net.URL
4449import java.net.http.HttpClient
4550import java.net.http.HttpRequest
@@ -48,12 +53,16 @@ import java.time.Duration
4853import java.util.*
4954import javax.swing.JLabel
5055import kotlin.coroutines.coroutineContext
56+ import kotlin.io.path.absolutePathString
57+ import kotlin.io.path.writeText
58+
5159
5260@Suppress(" UnstableApiUsage" , " OPT_IN_USAGE" )
5361class GitpodConnectionProvider : GatewayConnectionProvider {
5462 private val activeConnections = ConcurrentHashMap <String , LifetimeDefinition >()
5563 private val gitpod = service<GitpodConnectionService >()
5664 private val connectionHandleFactory = service<GitpodConnectionHandleFactory >()
65+ private val settings = service<GitpodSettingsState >()
5766
5867 private val httpClient = HttpClient .newBuilder()
5968 .followRedirects(HttpClient .Redirect .ALWAYS )
@@ -79,7 +88,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
7988 parameters[" debugWorkspace" ] == " true"
8089 )
8190
82- var connectionKeyId = " ${connectParams.gitpodHost} -${connectParams.resolvedWorkspaceId} -${connectParams.backendPort} "
91+ var connectionKeyId =
92+ " ${connectParams.gitpodHost} -${connectParams.resolvedWorkspaceId} -${connectParams.backendPort} "
8393
8494 var found = true
8595 val connectionLifetime = activeConnections.computeIfAbsent(connectionKeyId) {
@@ -185,7 +195,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
185195 if (WorkspaceInstance .isUpToDate(lastUpdate, update)) {
186196 continue
187197 }
188- resolvedIdeUrl = update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
198+ resolvedIdeUrl =
199+ update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
189200 lastUpdate = update
190201 if (! update.status.conditions.failed.isNullOrBlank()) {
191202 setErrorMessage(update.status.conditions.failed)
@@ -195,34 +206,42 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
195206 phaseMessage.text = " Preparing"
196207 statusMessage.text = " Preparing workspace..."
197208 }
209+
198210 " building" -> {
199211 phaseMessage.text = " Building"
200212 statusMessage.text = " Building workspace image..."
201213 }
214+
202215 " pending" -> {
203216 phaseMessage.text = " Preparing"
204217 statusMessage.text = " Allocating resources …"
205218 }
219+
206220 " creating" -> {
207221 phaseMessage.text = " Creating"
208222 statusMessage.text = " Pulling workspace image …"
209223 }
224+
210225 " initializing" -> {
211226 phaseMessage.text = " Starting"
212227 statusMessage.text = " Initializing workspace content …"
213228 }
229+
214230 " running" -> {
215231 phaseMessage.text = " Running"
216232 statusMessage.text = " Connecting..."
217233 }
234+
218235 " interrupted" -> {
219236 phaseMessage.text = " Starting"
220237 statusMessage.text = " Checking workspace …"
221238 }
239+
222240 " stopping" -> {
223241 phaseMessage.text = " Stopping"
224242 statusMessage.text = " "
225243 }
244+
226245 " stopped" -> {
227246 if (update.status.conditions.timeout.isNullOrBlank()) {
228247 phaseMessage.text = " Stopped"
@@ -231,6 +250,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
231250 }
232251 statusMessage.text = " "
233252 }
253+
234254 else -> {
235255 phaseMessage.text = " "
236256 statusMessage.text = " "
@@ -245,17 +265,28 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
245265 if (thinClientJob == null && update.status.phase == " running" ) {
246266 thinClientJob = launch {
247267 try {
248- val hostKeys = resolveHostKeys(URL (update.ideUrl), connectParams)
249- if (hostKeys.isNullOrEmpty()) {
250- setErrorMessage(" ${connectParams.gitpodHost} installation does not allow SSH access, public keys cannot be found" )
268+ val ideUrl = URL (resolvedIdeUrl)
269+ val ownerToken = client.server.getOwnerToken(update.workspaceId).await()
270+
271+ var credentials = resolveCredentialsWithDirectSSH(
272+ ideUrl,
273+ ownerToken,
274+ connectParams,
275+ )
276+ if (credentials == null ) {
277+ credentials = resolveCredentialsWithWebSocketTunnel(
278+ ideUrl,
279+ ownerToken,
280+ connectParams,
281+ connectionLifetime
282+ )
283+ }
284+ if (credentials == null ) {
285+ setErrorMessage(" ${connectParams.gitpodHost} installation does not allow SSH access" )
251286 return @launch
252287 }
253- val ownerToken = client.server.getOwnerToken(update.workspaceId).await()
254- val sshHostUrl =
255- URL (resolvedIdeUrl.replace(connectParams.resolvedWorkspaceId, " ${connectParams.resolvedWorkspaceId} .ssh" ))
256- val credentials =
257- resolveCredentials(sshHostUrl, connectParams.resolvedWorkspaceId, ownerToken, hostKeys)
258- val joinLink = resolveJoinLink(URL (resolvedIdeUrl), ownerToken, connectParams)
288+
289+ val joinLink = resolveJoinLink(ideUrl, ownerToken, connectParams)
259290 if (joinLink.isNullOrEmpty()) {
260291 setErrorMessage(" failed to fetch JetBrains Gateway Join Link." )
261292 return @launch
@@ -310,6 +341,95 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
310341 return connectionHandleFactory.createGitpodConnectionHandle(connectionLifetime, connectionPanel, connectParams)
311342 }
312343
344+ private suspend fun resolveCredentialsWithWebSocketTunnel (
345+ ideUrl : URL ,
346+ ownerToken : String ,
347+ connectParams : ConnectParams ,
348+ connectionLifetime : Lifetime ,
349+ ): RemoteCredentialsHolder ? {
350+ val keyPair = createSSHKeyPair(ideUrl, connectParams, ownerToken)
351+ if (keyPair == null || keyPair.privateKey.isNullOrEmpty()) {
352+ return null
353+ }
354+
355+ try {
356+ val privateKeyFile = kotlin.io.path.createTempFile()
357+ privateKeyFile.writeText(keyPair.privateKey)
358+ connectionLifetime.onTerminationOrNow {
359+ privateKeyFile.delete()
360+ }
361+
362+ val proxies = CommonProxy .getInstance().select(ideUrl)
363+ val sslContext = CertificateManager .getInstance().sslContext
364+ val sshWebSocketServer = GitpodWebSocketTunnelServer (
365+ " wss://${ideUrl.host} /_supervisor/tunnel/ssh" ,
366+ ownerToken,
367+ proxies,
368+ sslContext
369+ )
370+ sshWebSocketServer.start(connectionLifetime)
371+
372+ var hostKeys = emptyList<SSHHostKey >()
373+ if (keyPair.hostKey != null ) {
374+ hostKeys = listOf (SSHHostKey (keyPair.hostKey.type, keyPair.hostKey.value))
375+ }
376+
377+ return resolveCredentials(
378+ " localhost" ,
379+ sshWebSocketServer.port,
380+ " gitpod" ,
381+ null ,
382+ privateKeyFile.absolutePathString(),
383+ hostKeys
384+ )
385+ } catch (t: Throwable ) {
386+ thisLogger().error(
387+ " ${connectParams.gitpodHost} : web socket tunnel: failed to connect:" ,
388+ t
389+ )
390+ return null
391+ }
392+ }
393+
394+ private suspend fun resolveCredentialsWithDirectSSH (
395+ ideUrl : URL ,
396+ ownerToken : String ,
397+ connectParams : ConnectParams
398+ ): RemoteCredentialsHolder ? {
399+ if (settings.forceHttpTunnel) {
400+ return null
401+ }
402+ val hostKeys = resolveHostKeys(ideUrl, connectParams)
403+ if (hostKeys.isNullOrEmpty()) {
404+ thisLogger().error(" ${connectParams.gitpodHost} : direct SSH: failed to resolve host keys for" )
405+ return null
406+ }
407+
408+ try {
409+ val sshHostUrl =
410+ URL (
411+ ideUrl.toString().replace(
412+ connectParams.resolvedWorkspaceId,
413+ " ${connectParams.resolvedWorkspaceId} .ssh"
414+ )
415+ )
416+ return resolveCredentials(
417+ sshHostUrl.host,
418+ 22 ,
419+ connectParams.resolvedWorkspaceId,
420+ ownerToken,
421+ null ,
422+ hostKeys
423+ )
424+ } catch (t: Throwable ) {
425+ thisLogger().error(
426+ " ${connectParams.gitpodHost} : direct SSH: failed to resolve credentials" ,
427+ t
428+ )
429+ return null
430+ }
431+ }
432+
313433 private suspend fun resolveJoinLink (
314434 ideUrl : URL ,
315435 ownerToken : String ,
@@ -323,39 +443,66 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
323443 }
324444
325445 private fun resolveCredentials (
326- ideUrl : URL ,
327- userName : String ,
328- password : String ,
446+ host : String ,
447+ port : Int ,
448+ userName : String? ,
449+ password : String? ,
450+ privateKeyFile : String? ,
329451 hostKeys : List <SSHHostKey >
330452 ): RemoteCredentialsHolder {
331453 val credentials = RemoteCredentialsHolder ()
332- credentials.setHost(ideUrl.host)
333- credentials.port = 22
334- credentials.userName = userName
335- credentials.password = password
336- credentials.connectionBuilder(
454+ credentials.setHost(host)
455+ credentials.port = port
456+ if (userName != null ) {
457+ credentials.userName = userName
458+ }
459+ if (password != null ) {
460+ credentials.password = password
461+ } else if (privateKeyFile != null ) {
462+ credentials.setPrivateKeyFile(privateKeyFile)
463+ credentials.authType = AuthType .KEY_PAIR
464+ }
465+ var builder = credentials.connectionBuilder(
337466 null ,
338467 ProgressManager .getGlobalProgressIndicator(),
339468 false
340- )
341- .withParsingOpenSSHConfig(true )
342- .withSshConnectionConfig {
343- val hostKeyVerifier = it.hostKeyVerifier
344- if (hostKeyVerifier is OpenSshLikeHostKeyVerifier ) {
345- val acceptHostKey = acceptHostKey(ideUrl, hostKeys)
346- it.copy(
347- hostKeyVerifier = hostKeyVerifier.copy(
348- acceptChangedHostKey = acceptHostKey,
349- acceptUnknownHostKey = acceptHostKey
469+ ).withParsingOpenSSHConfig(true )
470+ if (hostKeys.isNotEmpty()) {
471+ builder = builder.withSshConnectionConfig {
472+ val hostKeyVerifier = it.hostKeyVerifier
473+ if (hostKeyVerifier is OpenSshLikeHostKeyVerifier ) {
474+ val acceptHostKey = acceptHostKey(host, hostKeys)
475+ it.copy(
476+ hostKeyVerifier = hostKeyVerifier.copy(
477+ acceptChangedHostKey = acceptHostKey,
478+ acceptUnknownHostKey = acceptHostKey
479+ )
350480 )
351- )
352- } else {
353- it
481+ } else {
482+ it
483+ }
354484 }
355- }.connect()
485+ }
486+ builder.connect()
356487 return credentials
357488 }
358489
490+ private suspend fun createSSHKeyPair (
491+ ideUrl : URL ,
492+ connectParams : ConnectParams ,
493+ ownerToken : String
494+ ): CreateSSHKeyPairResponse ? {
495+ val value =
496+ fetchWS(" https://${ideUrl.host} /_supervisor/v1/ssh_keys/create" , connectParams, ownerToken)
497+ if (value.isNullOrBlank()) {
498+ return null
499+ }
500+ return with (jacksonMapper) {
501+ propertyNamingStrategy = PropertyNamingStrategies .LowerCamelCaseStrategy ()
502+ readValue(value, object : TypeReference <CreateSSHKeyPairResponse >() {})
503+ }
504+ }
505+
359506 private suspend fun resolveHostKeys (
360507 ideUrl : URL ,
361508 connectParams : ConnectParams
@@ -395,12 +542,12 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
395542 }
396543
397544 private fun acceptHostKey (
398- ideUrl : URL ,
545+ host : String ,
399546 hostKeys : List <SSHHostKey >
400547 ): AskAboutHostKey {
401548 val hostKeysByType = hostKeys.groupBy({ it.type.lowercase() }) { it.hostKey }
402549 val acceptHostKey: AskAboutHostKey = { hostName, keyType, fingerprint, _ ->
403- if (hostName != ideUrl. host) {
550+ if (hostName != host) {
404551 false
405552 }
406553 val matchedHostKeys = hostKeysByType[keyType.lowercase()]
@@ -487,4 +634,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
487634 }
488635
489636 private data class SSHHostKey (val type : String , val hostKey : String )
637+
638+ private data class SSHPublicKey (val type : String , val value : String )
639+
640+ private data class CreateSSHKeyPairResponse (val privateKey : String , val hostKey : SSHPublicKey ? )
490641}
0 commit comments