Skip to content

Commit 90ab5a4

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

File tree

5 files changed

+421
-41
lines changed

5 files changed

+421
-41
lines changed

components/ide/jetbrains/gateway-plugin/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ dependencies {
6060
compileOnly("org.eclipse.jetty.websocket:websocket-api:9.4.44.v20210927")
6161
testImplementation(kotlin("test"))
6262
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.18.1")
63+
implementation("org.eclipse.jetty.websocket:javax-websocket-client-impl:9.4.44.v20210927")
6364
}
6465

6566
// Configure gradle-intellij-plugin plugin.

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

Lines changed: 187 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,12 +53,16 @@ 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 {
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

Comments
 (0)