@@ -13,7 +13,9 @@ import com.intellij.openapi.components.service
13
13
import com.intellij.openapi.diagnostic.thisLogger
14
14
import com.intellij.openapi.progress.ProgressManager
15
15
import com.intellij.openapi.ui.Messages
16
+ import com.intellij.remote.AuthType
16
17
import com.intellij.remote.RemoteCredentialsHolder
18
+ import com.intellij.remoteDev.util.onTerminationOrNow
17
19
import com.intellij.ssh.AskAboutHostKey
18
20
import com.intellij.ssh.OpenSshLikeHostKeyVerifier
19
21
import com.intellij.ssh.connectionBuilder
@@ -24,6 +26,10 @@ import com.intellij.ui.dsl.gridLayout.HorizontalAlign
24
26
import com.intellij.ui.dsl.gridLayout.VerticalAlign
25
27
import com.intellij.util.application
26
28
import 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
27
33
import com.intellij.util.ui.JBFont
28
34
import com.intellij.util.ui.JBUI
29
35
import com.intellij.util.ui.UIUtil
@@ -39,7 +45,6 @@ import com.jetbrains.rd.util.lifetime.LifetimeDefinition
39
45
import io.gitpod.gitpodprotocol.api.entities.WorkspaceInstance
40
46
import io.gitpod.jetbrains.icons.GitpodIcons
41
47
import kotlinx.coroutines.*
42
- import kotlinx.coroutines.future.await
43
48
import java.net.URL
44
49
import java.net.http.HttpClient
45
50
import java.net.http.HttpRequest
@@ -48,6 +53,9 @@ import java.time.Duration
48
53
import java.util.*
49
54
import javax.swing.JLabel
50
55
import kotlin.coroutines.coroutineContext
56
+ import kotlin.io.path.absolutePathString
57
+ import kotlin.io.path.writeText
58
+
51
59
52
60
@Suppress(" UnstableApiUsage" , " OPT_IN_USAGE" )
53
61
class GitpodConnectionProvider : GatewayConnectionProvider {
@@ -79,7 +87,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
79
87
parameters[" debugWorkspace" ] == " true"
80
88
)
81
89
82
- var connectionKeyId = " ${connectParams.gitpodHost} -${connectParams.resolvedWorkspaceId} -${connectParams.backendPort} "
90
+ var connectionKeyId =
91
+ " ${connectParams.gitpodHost} -${connectParams.resolvedWorkspaceId} -${connectParams.backendPort} "
83
92
84
93
var found = true
85
94
val connectionLifetime = activeConnections.computeIfAbsent(connectionKeyId) {
@@ -185,7 +194,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
185
194
if (WorkspaceInstance .isUpToDate(lastUpdate, update)) {
186
195
continue
187
196
}
188
- resolvedIdeUrl = update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
197
+ resolvedIdeUrl =
198
+ update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
189
199
lastUpdate = update
190
200
if (! update.status.conditions.failed.isNullOrBlank()) {
191
201
setErrorMessage(update.status.conditions.failed)
@@ -195,34 +205,42 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
195
205
phaseMessage.text = " Preparing"
196
206
statusMessage.text = " Preparing workspace..."
197
207
}
208
+
198
209
" building" -> {
199
210
phaseMessage.text = " Building"
200
211
statusMessage.text = " Building workspace image..."
201
212
}
213
+
202
214
" pending" -> {
203
215
phaseMessage.text = " Preparing"
204
216
statusMessage.text = " Allocating resources …"
205
217
}
218
+
206
219
" creating" -> {
207
220
phaseMessage.text = " Creating"
208
221
statusMessage.text = " Pulling workspace image …"
209
222
}
223
+
210
224
" initializing" -> {
211
225
phaseMessage.text = " Starting"
212
226
statusMessage.text = " Initializing workspace content …"
213
227
}
228
+
214
229
" running" -> {
215
230
phaseMessage.text = " Running"
216
231
statusMessage.text = " Connecting..."
217
232
}
233
+
218
234
" interrupted" -> {
219
235
phaseMessage.text = " Starting"
220
236
statusMessage.text = " Checking workspace …"
221
237
}
238
+
222
239
" stopping" -> {
223
240
phaseMessage.text = " Stopping"
224
241
statusMessage.text = " "
225
242
}
243
+
226
244
" stopped" -> {
227
245
if (update.status.conditions.timeout.isNullOrBlank()) {
228
246
phaseMessage.text = " Stopped"
@@ -231,6 +249,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
231
249
}
232
250
statusMessage.text = " "
233
251
}
252
+
234
253
else -> {
235
254
phaseMessage.text = " "
236
255
statusMessage.text = " "
@@ -245,17 +264,28 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
245
264
if (thinClientJob == null && update.status.phase == " running" ) {
246
265
thinClientJob = launch {
247
266
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" )
267
+ val ideUrl = URL (resolvedIdeUrl)
268
+ val ownerToken = client.server.getOwnerToken(update.workspaceId).await()
269
+
270
+ var credentials = resolveCredentialsWithDirectSSH(
271
+ ideUrl,
272
+ ownerToken,
273
+ connectParams,
274
+ )
275
+ if (credentials == null ) {
276
+ credentials = resolveCredentialsWithWebSocketTunnel(
277
+ ideUrl,
278
+ ownerToken,
279
+ connectParams,
280
+ connectionLifetime
281
+ )
282
+ }
283
+ if (credentials == null ) {
284
+ setErrorMessage(" ${connectParams.gitpodHost} installation does not allow SSH access" )
251
285
return @launch
252
286
}
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)
287
+
288
+ val joinLink = resolveJoinLink(ideUrl, ownerToken, connectParams)
259
289
if (joinLink.isNullOrEmpty()) {
260
290
setErrorMessage(" failed to fetch JetBrains Gateway Join Link." )
261
291
return @launch
@@ -310,6 +340,92 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
310
340
return connectionHandleFactory.createGitpodConnectionHandle(connectionLifetime, connectionPanel, connectParams)
311
341
}
312
342
343
+ private suspend fun resolveCredentialsWithWebSocketTunnel (
344
+ ideUrl : URL ,
345
+ ownerToken : String ,
346
+ connectParams : ConnectParams ,
347
+ connectionLifetime : Lifetime ,
348
+ ): RemoteCredentialsHolder ? {
349
+ val keyPair = createSSHKeyPair(ideUrl, connectParams, ownerToken)
350
+ if (keyPair == null || keyPair.privateKey.isNullOrEmpty()) {
351
+ return null
352
+ }
353
+
354
+ try {
355
+ val privateKeyFile = kotlin.io.path.createTempFile()
356
+ privateKeyFile.writeText(keyPair.privateKey)
357
+ connectionLifetime.onTerminationOrNow {
358
+ privateKeyFile.delete()
359
+ }
360
+
361
+ val proxies = CommonProxy .getInstance().select(ideUrl)
362
+ val sslContext = CertificateManager .getInstance().sslContext
363
+ val sshWebSocketServer = GitpodWebSocketTunnelServer (
364
+ " wss://${ideUrl.host} /_supervisor/tunnel/ssh" ,
365
+ ownerToken,
366
+ proxies,
367
+ sslContext
368
+ )
369
+ sshWebSocketServer.start(connectionLifetime)
370
+
371
+ var hostKeys = emptyList<SSHHostKey >()
372
+ if (keyPair.hostKey != null ) {
373
+ hostKeys = listOf (SSHHostKey (keyPair.hostKey.type, keyPair.hostKey.value))
374
+ }
375
+
376
+ return resolveCredentials(
377
+ " localhost" ,
378
+ sshWebSocketServer.port,
379
+ " gitpod" ,
380
+ null ,
381
+ privateKeyFile.absolutePathString(),
382
+ hostKeys
383
+ )
384
+ } catch (t: Throwable ) {
385
+ thisLogger().error(
386
+ " ${connectParams.gitpodHost} : web socket tunnelÏ: failed to connect:" ,
387
+ t
388
+ )
389
+ return null
390
+ }
391
+ }
392
+
393
+ private suspend fun resolveCredentialsWithDirectSSH (
394
+ ideUrl : URL ,
395
+ ownerToken : String ,
396
+ connectParams : ConnectParams
397
+ ): RemoteCredentialsHolder ? {
398
+ val hostKeys = resolveHostKeys(ideUrl, connectParams)
399
+ if (hostKeys.isNullOrEmpty()) {
400
+ thisLogger().error(" ${connectParams.gitpodHost} : direct SSH: failed to resolve host keys for" )
401
+ return null
402
+ }
403
+
404
+ try {
405
+ val sshHostUrl =
406
+ URL (
407
+ ideUrl.toString().replace(
408
+ connectParams.resolvedWorkspaceId,
409
+ " ${connectParams.resolvedWorkspaceId} .ssh"
410
+ )
411
+ )
412
+ return resolveCredentials(
413
+ sshHostUrl.host,
414
+ 22 ,
415
+ connectParams.resolvedWorkspaceId,
416
+ ownerToken,
417
+ null ,
418
+ hostKeys
419
+ )
420
+ } catch (t: Throwable ) {
421
+ thisLogger().error(
422
+ " ${connectParams.gitpodHost} : direct SSH: failed to resolve credentials" ,
423
+ t
424
+ )
425
+ return null
426
+ }
427
+ }
428
+
313
429
private suspend fun resolveJoinLink (
314
430
ideUrl : URL ,
315
431
ownerToken : String ,
@@ -323,39 +439,66 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
323
439
}
324
440
325
441
private fun resolveCredentials (
326
- ideUrl : URL ,
327
- userName : String ,
328
- password : String ,
442
+ host : String ,
443
+ port : Int ,
444
+ userName : String? ,
445
+ password : String? ,
446
+ privateKeyFile : String? ,
329
447
hostKeys : List <SSHHostKey >
330
448
): RemoteCredentialsHolder {
331
449
val credentials = RemoteCredentialsHolder ()
332
- credentials.setHost(ideUrl.host)
333
- credentials.port = 22
334
- credentials.userName = userName
335
- credentials.password = password
336
- credentials.connectionBuilder(
450
+ credentials.setHost(host)
451
+ credentials.port = port
452
+ if (userName != null ) {
453
+ credentials.userName = userName
454
+ }
455
+ if (password != null ) {
456
+ credentials.password = password
457
+ } else if (privateKeyFile != null ) {
458
+ credentials.setPrivateKeyFile(privateKeyFile)
459
+ credentials.authType = AuthType .KEY_PAIR
460
+ }
461
+ var builder = credentials.connectionBuilder(
337
462
null ,
338
463
ProgressManager .getGlobalProgressIndicator(),
339
464
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
465
+ ).withParsingOpenSSHConfig(true )
466
+ if (hostKeys.isNotEmpty()) {
467
+ builder = builder.withSshConnectionConfig {
468
+ val hostKeyVerifier = it.hostKeyVerifier
469
+ if (hostKeyVerifier is OpenSshLikeHostKeyVerifier ) {
470
+ val acceptHostKey = acceptHostKey(host, hostKeys)
471
+ it.copy(
472
+ hostKeyVerifier = hostKeyVerifier.copy(
473
+ acceptChangedHostKey = acceptHostKey,
474
+ acceptUnknownHostKey = acceptHostKey
475
+ )
350
476
)
351
- )
352
- } else {
353
- it
477
+ } else {
478
+ it
479
+ }
354
480
}
355
- }.connect()
481
+ }
482
+ builder.connect()
356
483
return credentials
357
484
}
358
485
486
+ private suspend fun createSSHKeyPair (
487
+ ideUrl : URL ,
488
+ connectParams : ConnectParams ,
489
+ ownerToken : String
490
+ ): CreateSSHKeyPairResponse ? {
491
+ val value =
492
+ fetchWS(" https://${ideUrl.host} /_supervisor/v1/ssh_keys/create" , connectParams, ownerToken)
493
+ if (value.isNullOrBlank()) {
494
+ return null
495
+ }
496
+ return with (jacksonMapper) {
497
+ propertyNamingStrategy = PropertyNamingStrategies .LowerCamelCaseStrategy ()
498
+ readValue(value, object : TypeReference <CreateSSHKeyPairResponse >() {})
499
+ }
500
+ }
501
+
359
502
private suspend fun resolveHostKeys (
360
503
ideUrl : URL ,
361
504
connectParams : ConnectParams
@@ -395,12 +538,12 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
395
538
}
396
539
397
540
private fun acceptHostKey (
398
- ideUrl : URL ,
541
+ host : String ,
399
542
hostKeys : List <SSHHostKey >
400
543
): AskAboutHostKey {
401
544
val hostKeysByType = hostKeys.groupBy({ it.type.lowercase() }) { it.hostKey }
402
545
val acceptHostKey: AskAboutHostKey = { hostName, keyType, fingerprint, _ ->
403
- if (hostName != ideUrl. host) {
546
+ if (hostName != host) {
404
547
false
405
548
}
406
549
val matchedHostKeys = hostKeysByType[keyType.lowercase()]
@@ -487,4 +630,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
487
630
}
488
631
489
632
private data class SSHHostKey (val type : String , val hostKey : String )
633
+
634
+ private data class SSHPublicKey (val type : String , val value : String )
635
+
636
+ private data class CreateSSHKeyPairResponse (val privateKey : String , val hostKey : SSHPublicKey ? )
490
637
}
0 commit comments