@@ -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,12 +53,16 @@ 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 {
54
62
private val activeConnections = ConcurrentHashMap <String , LifetimeDefinition >()
55
63
private val gitpod = service<GitpodConnectionService >()
56
64
private val connectionHandleFactory = service<GitpodConnectionHandleFactory >()
65
+ private val settings = service<GitpodSettingsState >()
57
66
58
67
private val httpClient = HttpClient .newBuilder()
59
68
.followRedirects(HttpClient .Redirect .ALWAYS )
@@ -79,7 +88,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
79
88
parameters[" debugWorkspace" ] == " true"
80
89
)
81
90
82
- var connectionKeyId = " ${connectParams.gitpodHost} -${connectParams.resolvedWorkspaceId} -${connectParams.backendPort} "
91
+ var connectionKeyId =
92
+ " ${connectParams.gitpodHost} -${connectParams.resolvedWorkspaceId} -${connectParams.backendPort} "
83
93
84
94
var found = true
85
95
val connectionLifetime = activeConnections.computeIfAbsent(connectionKeyId) {
@@ -185,7 +195,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
185
195
if (WorkspaceInstance .isUpToDate(lastUpdate, update)) {
186
196
continue
187
197
}
188
- resolvedIdeUrl = update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
198
+ resolvedIdeUrl =
199
+ update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
189
200
lastUpdate = update
190
201
if (! update.status.conditions.failed.isNullOrBlank()) {
191
202
setErrorMessage(update.status.conditions.failed)
@@ -195,34 +206,42 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
195
206
phaseMessage.text = " Preparing"
196
207
statusMessage.text = " Preparing workspace..."
197
208
}
209
+
198
210
" building" -> {
199
211
phaseMessage.text = " Building"
200
212
statusMessage.text = " Building workspace image..."
201
213
}
214
+
202
215
" pending" -> {
203
216
phaseMessage.text = " Preparing"
204
217
statusMessage.text = " Allocating resources …"
205
218
}
219
+
206
220
" creating" -> {
207
221
phaseMessage.text = " Creating"
208
222
statusMessage.text = " Pulling workspace image …"
209
223
}
224
+
210
225
" initializing" -> {
211
226
phaseMessage.text = " Starting"
212
227
statusMessage.text = " Initializing workspace content …"
213
228
}
229
+
214
230
" running" -> {
215
231
phaseMessage.text = " Running"
216
232
statusMessage.text = " Connecting..."
217
233
}
234
+
218
235
" interrupted" -> {
219
236
phaseMessage.text = " Starting"
220
237
statusMessage.text = " Checking workspace …"
221
238
}
239
+
222
240
" stopping" -> {
223
241
phaseMessage.text = " Stopping"
224
242
statusMessage.text = " "
225
243
}
244
+
226
245
" stopped" -> {
227
246
if (update.status.conditions.timeout.isNullOrBlank()) {
228
247
phaseMessage.text = " Stopped"
@@ -231,6 +250,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
231
250
}
232
251
statusMessage.text = " "
233
252
}
253
+
234
254
else -> {
235
255
phaseMessage.text = " "
236
256
statusMessage.text = " "
@@ -245,17 +265,28 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
245
265
if (thinClientJob == null && update.status.phase == " running" ) {
246
266
thinClientJob = launch {
247
267
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" )
251
286
return @launch
252
287
}
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)
259
290
if (joinLink.isNullOrEmpty()) {
260
291
setErrorMessage(" failed to fetch JetBrains Gateway Join Link." )
261
292
return @launch
@@ -310,6 +341,95 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
310
341
return connectionHandleFactory.createGitpodConnectionHandle(connectionLifetime, connectionPanel, connectParams)
311
342
}
312
343
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
+
313
433
private suspend fun resolveJoinLink (
314
434
ideUrl : URL ,
315
435
ownerToken : String ,
@@ -323,39 +443,66 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
323
443
}
324
444
325
445
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? ,
329
451
hostKeys : List <SSHHostKey >
330
452
): RemoteCredentialsHolder {
331
453
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(
337
466
null ,
338
467
ProgressManager .getGlobalProgressIndicator(),
339
468
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
+ )
350
480
)
351
- )
352
- } else {
353
- it
481
+ } else {
482
+ it
483
+ }
354
484
}
355
- }.connect()
485
+ }
486
+ builder.connect()
356
487
return credentials
357
488
}
358
489
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
+
359
506
private suspend fun resolveHostKeys (
360
507
ideUrl : URL ,
361
508
connectParams : ConnectParams
@@ -395,12 +542,12 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
395
542
}
396
543
397
544
private fun acceptHostKey (
398
- ideUrl : URL ,
545
+ host : String ,
399
546
hostKeys : List <SSHHostKey >
400
547
): AskAboutHostKey {
401
548
val hostKeysByType = hostKeys.groupBy({ it.type.lowercase() }) { it.hostKey }
402
549
val acceptHostKey: AskAboutHostKey = { hostName, keyType, fingerprint, _ ->
403
- if (hostName != ideUrl. host) {
550
+ if (hostName != host) {
404
551
false
405
552
}
406
553
val matchedHostKeys = hostKeysByType[keyType.lowercase()]
@@ -487,4 +634,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
487
634
}
488
635
489
636
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 ? )
490
641
}
0 commit comments