Skip to content

Commit 31136a8

Browse files
committed
[jb-gw] support ssh over web socket
1 parent 14b6972 commit 31136a8

File tree

2 files changed

+360
-36
lines changed

2 files changed

+360
-36
lines changed

components/ide/jetbrains/gateway-plugin/src/main/kotlin/io/gitpod/jetbrains/gateway/GitpodConnectionProvider.kt

Lines changed: 183 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import com.intellij.openapi.components.service
1313
import com.intellij.openapi.diagnostic.thisLogger
1414
import com.intellij.openapi.progress.ProgressManager
1515
import com.intellij.openapi.ui.Messages
16+
import com.intellij.remote.AuthType
1617
import com.intellij.remote.RemoteCredentialsHolder
18+
import com.intellij.remoteDev.util.onTerminationOrNow
1719
import com.intellij.ssh.AskAboutHostKey
1820
import com.intellij.ssh.OpenSshLikeHostKeyVerifier
1921
import com.intellij.ssh.connectionBuilder
@@ -24,6 +26,10 @@ import com.intellij.ui.dsl.gridLayout.HorizontalAlign
2426
import com.intellij.ui.dsl.gridLayout.VerticalAlign
2527
import com.intellij.util.application
2628
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
2733
import com.intellij.util.ui.JBFont
2834
import com.intellij.util.ui.JBUI
2935
import com.intellij.util.ui.UIUtil
@@ -39,7 +45,6 @@ import com.jetbrains.rd.util.lifetime.LifetimeDefinition
3945
import io.gitpod.gitpodprotocol.api.entities.WorkspaceInstance
4046
import io.gitpod.jetbrains.icons.GitpodIcons
4147
import kotlinx.coroutines.*
42-
import kotlinx.coroutines.future.await
4348
import java.net.URL
4449
import java.net.http.HttpClient
4550
import java.net.http.HttpRequest
@@ -48,6 +53,9 @@ import java.time.Duration
4853
import java.util.*
4954
import javax.swing.JLabel
5055
import kotlin.coroutines.coroutineContext
56+
import kotlin.io.path.absolutePathString
57+
import kotlin.io.path.writeText
58+
5159

5260
@Suppress("UnstableApiUsage", "OPT_IN_USAGE")
5361
class GitpodConnectionProvider : GatewayConnectionProvider {
@@ -79,7 +87,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
7987
parameters["debugWorkspace"] == "true"
8088
)
8189

82-
var connectionKeyId = "${connectParams.gitpodHost}-${connectParams.resolvedWorkspaceId}-${connectParams.backendPort}"
90+
var connectionKeyId =
91+
"${connectParams.gitpodHost}-${connectParams.resolvedWorkspaceId}-${connectParams.backendPort}"
8392

8493
var found = true
8594
val connectionLifetime = activeConnections.computeIfAbsent(connectionKeyId) {
@@ -185,7 +194,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
185194
if (WorkspaceInstance.isUpToDate(lastUpdate, update)) {
186195
continue
187196
}
188-
resolvedIdeUrl = update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
197+
resolvedIdeUrl =
198+
update.ideUrl.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId)
189199
lastUpdate = update
190200
if (!update.status.conditions.failed.isNullOrBlank()) {
191201
setErrorMessage(update.status.conditions.failed)
@@ -195,34 +205,42 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
195205
phaseMessage.text = "Preparing"
196206
statusMessage.text = "Preparing workspace..."
197207
}
208+
198209
"building" -> {
199210
phaseMessage.text = "Building"
200211
statusMessage.text = "Building workspace image..."
201212
}
213+
202214
"pending" -> {
203215
phaseMessage.text = "Preparing"
204216
statusMessage.text = "Allocating resources …"
205217
}
218+
206219
"creating" -> {
207220
phaseMessage.text = "Creating"
208221
statusMessage.text = "Pulling workspace image …"
209222
}
223+
210224
"initializing" -> {
211225
phaseMessage.text = "Starting"
212226
statusMessage.text = "Initializing workspace content …"
213227
}
228+
214229
"running" -> {
215230
phaseMessage.text = "Running"
216231
statusMessage.text = "Connecting..."
217232
}
233+
218234
"interrupted" -> {
219235
phaseMessage.text = "Starting"
220236
statusMessage.text = "Checking workspace …"
221237
}
238+
222239
"stopping" -> {
223240
phaseMessage.text = "Stopping"
224241
statusMessage.text = ""
225242
}
243+
226244
"stopped" -> {
227245
if (update.status.conditions.timeout.isNullOrBlank()) {
228246
phaseMessage.text = "Stopped"
@@ -231,6 +249,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
231249
}
232250
statusMessage.text = ""
233251
}
252+
234253
else -> {
235254
phaseMessage.text = ""
236255
statusMessage.text = ""
@@ -245,17 +264,28 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
245264
if (thinClientJob == null && update.status.phase == "running") {
246265
thinClientJob = launch {
247266
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")
251285
return@launch
252286
}
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)
259289
if (joinLink.isNullOrEmpty()) {
260290
setErrorMessage("failed to fetch JetBrains Gateway Join Link.")
261291
return@launch
@@ -310,6 +340,92 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
310340
return connectionHandleFactory.createGitpodConnectionHandle(connectionLifetime, connectionPanel, connectParams)
311341
}
312342

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+
313429
private suspend fun resolveJoinLink(
314430
ideUrl: URL,
315431
ownerToken: String,
@@ -323,39 +439,66 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
323439
}
324440

325441
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?,
329447
hostKeys: List<SSHHostKey>
330448
): RemoteCredentialsHolder {
331449
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(
337462
null,
338463
ProgressManager.getGlobalProgressIndicator(),
339464
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+
)
350476
)
351-
)
352-
} else {
353-
it
477+
} else {
478+
it
479+
}
354480
}
355-
}.connect()
481+
}
482+
builder.connect()
356483
return credentials
357484
}
358485

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+
359502
private suspend fun resolveHostKeys(
360503
ideUrl: URL,
361504
connectParams: ConnectParams
@@ -395,12 +538,12 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
395538
}
396539

397540
private fun acceptHostKey(
398-
ideUrl: URL,
541+
host: String,
399542
hostKeys: List<SSHHostKey>
400543
): AskAboutHostKey {
401544
val hostKeysByType = hostKeys.groupBy({ it.type.lowercase() }) { it.hostKey }
402545
val acceptHostKey: AskAboutHostKey = { hostName, keyType, fingerprint, _ ->
403-
if (hostName != ideUrl.host) {
546+
if (hostName != host) {
404547
false
405548
}
406549
val matchedHostKeys = hostKeysByType[keyType.lowercase()]
@@ -487,4 +630,8 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
487630
}
488631

489632
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?)
490637
}

0 commit comments

Comments
 (0)