@@ -13,13 +13,9 @@ import com.coder.toolbox.cli.gpg.VerificationResult
1313import com.coder.toolbox.cli.gpg.VerificationResult.Failed
1414import com.coder.toolbox.cli.gpg.VerificationResult.Invalid
1515import com.coder.toolbox.sdk.CoderHttpClientBuilder
16- import com.coder.toolbox.sdk.v2.models.Workspace
17- import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
1816import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW
1917import com.coder.toolbox.util.InvalidVersionException
2018import com.coder.toolbox.util.SemVer
21- import com.coder.toolbox.util.escape
22- import com.coder.toolbox.util.escapeSubcommand
2319import com.coder.toolbox.util.safeHost
2420import com.squareup.moshi.Json
2521import com.squareup.moshi.JsonClass
@@ -277,18 +273,19 @@ class CoderCLIManager(
277273 /* *
278274 * Start a workspace. Throws if the command execution fails.
279275 */
280- fun startWorkspace (workspaceOwner : String , workspaceName : String , feats : Features = features): String {
276+ internal fun startWorkspace (wsAddress : WorkspaceAddress , feats : Features = features): String {
281277 val args = mutableListOf (
282278 " --global-config" ,
283279 coderConfigPath.toString(),
284280 " start" ,
285281 " --yes" ,
286- " $workspaceOwner /$workspaceName "
287282 )
288283
289284 if (feats.buildReason) {
290285 args.addAll(listOf (" --reason" , " jetbrains_connection" ))
291286 }
287+ args.add(" --" )
288+ args.add(wsAddress.ownerAndWsName)
292289
293290 return exec(* args.toTypedArray())
294291 }
@@ -298,12 +295,12 @@ class CoderCLIManager(
298295 *
299296 * This can take supported features for testing purposes only.
300297 */
301- fun configSsh (
302- wsWithAgents : Set <Pair < Workspace , WorkspaceAgent > >,
298+ internal fun configSsh (
299+ workspaceAddresses : Set <WorkspaceAddress >,
303300 feats : Features = features,
304301 ) {
305302 context.logger.info(" Configuring SSH config at ${context.settingsStore.sshConfigPath} " )
306- writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents , feats))
303+ writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaceAddresses , feats))
307304 context.logger.info(" Finished configuring SSH config" )
308305 }
309306
@@ -326,34 +323,40 @@ class CoderCLIManager(
326323 */
327324 private fun modifySSHConfig (
328325 contents : String? ,
329- wsWithAgents : Set <Pair < Workspace , WorkspaceAgent > >,
326+ workspaceAddresses : Set <WorkspaceAddress >,
330327 feats : Features ,
331328 ): String? {
332329 val host = deploymentURL.safeHost()
333330 val startBlock = " # --- START CODER JETBRAINS TOOLBOX $host "
334331 val endBlock = " # --- END CODER JETBRAINS TOOLBOX $host "
335- val isRemoving = wsWithAgents .isEmpty()
332+ val isRemoving = workspaceAddresses .isEmpty()
336333 val baseArgs =
337334 listOfNotNull(
338- escape( localBinaryPath.toString() ),
335+ localBinaryPath.toString(),
339336 " --global-config" ,
340- escape( coderConfigPath.toString() ),
337+ coderConfigPath.toString(),
341338 // CODER_URL might be set, and it will override the URL file in
342339 // the config directory, so override that here to make sure we
343340 // always use the correct URL.
344341 " --url" ,
345- escape( deploymentURL.toString() ),
342+ deploymentURL.toString(),
346343 context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { " --header-command" },
347- context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?. let { escapeSubcommand(it) } ,
344+ context.settingsStore.headerCommand?.takeIf { it.isNotBlank() },
348345 " ssh" ,
349346 " --stdio" ,
350347 if (context.settingsStore.disableAutostart && feats.disableAutostart) " --disable-autostart" else null ,
351- " --network-info-dir ${escape(context.settingsStore.networkInfoDir)} "
348+ " --network-info-dir" ,
349+ context.settingsStore.networkInfoDir,
352350 )
353- val proxyArgs = baseArgs + listOfNotNull(
354- context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { " --log-dir ${escape(it)} -v" },
355- if (feats.reportWorkspaceUsage) " --usage-app=jetbrains" else null ,
356- )
351+ val proxyArgs = buildList {
352+ addAll(baseArgs)
353+ context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let {
354+ add(" --log-dir" )
355+ add(it)
356+ add(" -v" )
357+ }
358+ if (feats.reportWorkspaceUsage) add(" --usage-app=jetbrains" )
359+ }
357360 val extraConfig = context.settingsStore.sshConfigOptions
358361 ?.takeIf { it.isNotBlank() }
359362 ?.let { " \n " + it.prependIndent(" " ) }
@@ -367,25 +370,38 @@ class CoderCLIManager(
367370 """ .trimIndent()
368371
369372 val blockContent = if (context.settingsStore.isSshWildcardConfigEnabled && feats.wildcardSsh) {
373+ val hostnamePrefix = WorkspaceAddress .wildcardSshHostPrefix(deploymentURL.safeHost())
374+ val proxyCommand = ProxyCommandBuilder ()
375+ .arguments(proxyArgs)
376+ .argument(" --ssh-host-prefix" )
377+ .argument(" $hostnamePrefix --" )
378+ .argument(" --" )
379+ .sshToken(" %h" )
380+ .render()
370381 startBlock + System .lineSeparator() +
371382 """
372- Host ${getHostnamePrefix(deploymentURL)} --*
373- ProxyCommand ${proxyArgs.joinToString( " " )} --ssh-host-prefix ${getHostnamePrefix(deploymentURL)} -- %h
383+ Host $hostnamePrefix --*
384+ ProxyCommand $proxyCommand
374385 """ .trimIndent()
375386 .plus(" \n " + options.prependIndent(" " ))
376387 .plus(extraConfig)
377388 .plus(" \n " )
378389 .replace(" \n " , System .lineSeparator()) +
379390 System .lineSeparator() + endBlock
380391 } else {
381- wsWithAgents .joinToString(
392+ workspaceAddresses .joinToString(
382393 System .lineSeparator(),
383394 startBlock + System .lineSeparator(),
384395 System .lineSeparator() + endBlock,
385- transform = {
396+ transform = { workspaceAddress ->
397+ val proxyCommand = ProxyCommandBuilder ()
398+ .arguments(proxyArgs)
399+ .argument(" --" )
400+ .argument(workspaceAddress.ownerWsAndAgentName)
401+ .render()
386402 """
387- Host ${getHostname(deploymentURL, it.workspace(), it.agent() )}
388- ProxyCommand ${proxyArgs.joinToString( " " )} ${getWsByOwner(it.workspace(), it.agent())}
403+ Host ${getHostname(deploymentURL, workspaceAddress )}
404+ ProxyCommand $proxyCommand
389405 """ .trimIndent()
390406 .plus(" \n " + options.prependIndent(" " ))
391407 .plus(extraConfig)
@@ -400,15 +416,14 @@ class CoderCLIManager(
400416 return blockContent + System .lineSeparator()
401417 }
402418
403- val start = " (\\ s*)$startBlock " .toRegex().find(contents)
404- val end = " $endBlock (\\ s*)" .toRegex().find(contents)
419+ val managedBlock = findManagedBlock(contents, startBlock, endBlock)
405420
406- if (start == null && end == null && isRemoving) {
421+ if (managedBlock == null && isRemoving) {
407422 context.logger.info(" No workspaces and no existing config blocks to remove" )
408423 return null
409424 }
410425
411- if (start == null && end == null ) {
426+ if (managedBlock == null ) {
412427 context.logger.info(" Appending config block" )
413428 val toAppend =
414429 if (contents.isEmpty()) {
@@ -422,15 +437,7 @@ class CoderCLIManager(
422437 return toAppend + System .lineSeparator()
423438 }
424439
425- if (start == null ) {
426- throw SSHConfigFormatException (" End block exists but no start block" )
427- }
428- if (end == null ) {
429- throw SSHConfigFormatException (" Start block exists but no end block" )
430- }
431- if (start.range.first > end.range.first) {
432- throw SSHConfigFormatException (" Start block found after end block" )
433- }
440+ val (start, end) = managedBlock
434441
435442 if (isRemoving) {
436443 context.logger.info(" No workspaces; removing config block" )
@@ -454,6 +461,26 @@ class CoderCLIManager(
454461 ).joinToString(" " )
455462 }
456463
464+ /* *
465+ * Locate a managed block, using the final end marker so a rewrite also
466+ * removes content following an end marker injected by a vulnerable version.
467+ */
468+ private fun findManagedBlock (contents : String , startMarker : String , endMarker : String ): ManagedBlock ? {
469+ val start = " (\\ s*)${Regex .escape(startMarker)} " .toRegex().find(contents)
470+ val allEnds = " ${Regex .escape(endMarker)} (\\ s*)" .toRegex().findAll(contents).toList()
471+
472+ if (start == null && allEnds.isEmpty()) return null
473+ if (start == null ) throw SSHConfigFormatException (" End block exists but no start block" )
474+
475+ val end = allEnds.lastOrNull { it.range.first > start.range.first }
476+ ? : if (allEnds.isEmpty()) {
477+ throw SSHConfigFormatException (" Start block exists but no end block" )
478+ } else {
479+ throw SSHConfigFormatException (" Start block found after end block" )
480+ }
481+ return ManagedBlock (start, end)
482+ }
483+
457484 /* *
458485 * Write the provided SSH config or do nothing if null.
459486 */
@@ -563,24 +590,17 @@ class CoderCLIManager(
563590 }
564591 }
565592
566- fun getHostname (url : URL , ws : Workspace , agent : WorkspaceAgent ): String {
593+ internal fun getHostname (url : URL , workspaceAddress : WorkspaceAddress ): String {
567594 return if (context.settingsStore.isSshWildcardConfigEnabled && features.wildcardSsh) {
568- " ${getHostnamePrefix (url)} -- ${ws.ownerName} -- ${ws.name} . ${agent.name} "
595+ workspaceAddress.wildcardSshHostAlias (url.safeHost())
569596 } else {
570- " coder-jetbrains-toolbox-- ${ws.ownerName} -- ${ws.name} . ${agent.name} -- ${ url.safeHost()} "
597+ workspaceAddress.sshHostAlias( url.safeHost())
571598 }
572599 }
573600
574601 companion object {
575- private val tokenRegex = " --token [^ ]+ " .toRegex( )
602+ private data class ManagedBlock ( val start : MatchResult , val end : MatchResult )
576603
577- private fun getHostnamePrefix (url : URL ): String = " coder-jetbrains-toolbox-${url.safeHost()} "
578-
579- private fun getWsByOwner (ws : Workspace , agent : WorkspaceAgent ): String =
580- " ${ws.ownerName} /${ws.name} .${agent.name} "
581-
582- private fun Pair <Workspace , WorkspaceAgent >.workspace () = this .first
583-
584- private fun Pair <Workspace , WorkspaceAgent >.agent () = this .second
604+ private val tokenRegex = " --token [^ ]+" .toRegex()
585605 }
586606}
0 commit comments