Skip to content

feat: coder connect integration #482

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 14 commits into from
Prev Previous commit
Next Next commit
switch to coder connect dynamically
  • Loading branch information
ethanndickson committed Apr 17, 2025
commit 195151a2b8d80d7cb1510471be3cd5516e8a5c85
35 changes: 33 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AxiosInstance } from "axios"
import { AxiosInstance, isAxiosError } from "axios"
import { spawn } from "child_process"
import { Api } from "coder/site/src/api/api"
import { ProvisionerJobLog, Workspace } from "coder/site/src/api/typesGenerated"
import { ProvisionerJobLog, SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated"
import { FetchLikeInit } from "eventsource"
import fs from "fs/promises"
import { ProxyAgent } from "proxy-agent"
Expand Down Expand Up @@ -280,3 +280,34 @@ export async function waitForBuild(
writeEmitter.fire(`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`)
return updatedWorkspace
}

export async function fetchSSHConfig(restClient: Api, vsc: typeof vscode): Promise<SSHConfigResponse> {
try {
const sshConfig = await restClient.getDeploymentSSHConfig()
return {
hostname_prefix: sshConfig.hostname_prefix,
hostname_suffix: sshConfig.hostname_suffix ?? "coder",
ssh_config_options: sshConfig.ssh_config_options,
}
} catch (error) {
if (!isAxiosError(error)) {
throw error
}
switch (error.response?.status) {
case 404: {
// Very old deployment that doesn't support SSH config
return {
hostname_prefix: "coder",
hostname_suffix: "coder",
ssh_config_options: {},
}
}
case 401: {
vsc.window.showErrorMessage("Your session expired...")
throw error
}
default:
throw error
}
}
}
52 changes: 6 additions & 46 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { isAxiosError } from "axios"
import { Api } from "coder/site/src/api/api"
import { getErrorMessage } from "coder/site/src/api/errors"
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
import { lookup } from "dns"
import ipRangeCheck from "ip-range-check"
import { promisify } from "util"
import * as vscode from "vscode"
import { makeCoderSdk, needToken } from "./api"
import { fetchSSHConfig, makeCoderSdk, needToken } from "./api"
import { extractAgents } from "./api-helper"
import { CertificateError } from "./error"
import { Storage } from "./storage"
import { toRemoteAuthority, toSafeHost } from "./util"
import { maybeCoderConnectAddr, toRemoteAuthority, toSafeHost } from "./util"
import { OpenableTreeItem } from "./workspacesProvider"

export class Commands {
Expand Down Expand Up @@ -573,10 +569,10 @@ export class Commands {
// if the workspace is stopped, in which case we can't use Coder Connect
// When called from `open`, the workspaceAgent will always be set.
if (workspaceAgent) {
let hostnameSuffix = "coder"
let sshConfig
try {
// If the field was undefined, it's an older server, and always 'coder'
hostnameSuffix = (await this.fetchHostnameSuffix()) ?? hostnameSuffix
// Fetch (or get defaults) for the SSH config.
sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension has an SSH config section which I assume is not going apply to Coder Connect, is that right? As well as anything the user has added to their SSH config manually.

Wondering if we should update the setting description to say the variables will not apply if there is a Coder Connect session we can use.

Speaking of settings, should using Coder Connect be behind a setting as well? Or a prompt? "We see Coder Connect, do you want to use that?" or similar.

Copy link
Member Author

@ethanndickson ethanndickson Apr 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should update the setting description to say the variables will not apply if there is a Coder Connect session we can use.

Will add.

As well as anything the user has added to their SSH config manually.

These will apply, as long as they're on the Coder Connect hostname (so, if they just specify those options when running coder config-ssh it'll get added to *.<hostnamesuffix>).

Speaking of settings, should using Coder Connect be behind a setting as well? Or a prompt? "We see Coder Connect, do you want to use that?" or similar.

I don't think this is necessary, I can't imagine a scenario where the user would have Coder Connect on and not want to use it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary, I can't imagine a scenario where the user would have Coder Connect on and not want to use it?

I agree.

Copy link
Member

@code-asher code-asher Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the tricky part is that the user might not realize it will use Connect. So, they have X11Forwarding yes in their VS Code config or in their SSH config under Host coder-vscode--*, they are trying out Connect, one day they update the plugin, and suddenly no more forwarding. Some days it does forward, some days does not, depending on whether Connect is up (idk if this is realistic usage/behavior though).

So there should be clear messaging what the plugin is going to do IMO, somewhere. Silently changing behavior feels bad to me. But, I am neither a VS Code nor Connect user, so take what I say with a grain of salt. 😆 Maybe the behavior change will be obvious to users.

If we could make the same SSH settings apply either way, then that would be a different story.

Copy link
Member Author

@ethanndickson ethanndickson Apr 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we could make the same SSH settings apply either way, then that would be a different story.

With the updated coder ssh-config, any options applied to that will also apply to Coder Connect via VSCode, at least.

Though if we change the approach to what I discussed below that solves this issue too ig

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the updated coder ssh-config, any options applied to that will also apply to Coder Connect via VSCode, at least.

That is quite nice!

} catch (error) {
const message = getErrorMessage(error, "no response from the server")
this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`)
Expand All @@ -592,7 +588,7 @@ export class Commands {
workspaceAgent,
workspaceName,
workspaceOwner,
hostnameSuffix,
sshConfig.hostname_suffix,
)
if (coderConnectAddr) {
remoteAuthority = `ssh-remote+${coderConnectAddr}`
Expand Down Expand Up @@ -656,42 +652,6 @@ export class Commands {
reuseWindow: !newWindow,
})
}

private async fetchHostnameSuffix(): Promise<string | undefined> {
try {
const sshConfig = await this.restClient.getDeploymentSSHConfig()
return sshConfig.hostname_suffix
} catch (error) {
if (!isAxiosError(error)) {
throw error
}
switch (error.response?.status) {
case 404: {
// Likely a very old deployment, just use the default.
break
}
default:
throw error
}
}
}
}

async function maybeCoderConnectAddr(
agent: string,
workspace: string,
owner: string,
hostnameSuffix: string,
): Promise<string | undefined> {
const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}`
try {
const res = await promisify(lookup)(coderConnectHostname)
// Captive DNS portals may return an unrelated address, so we check it's
// within the Coder Service Prefix.
return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined
} catch {
return undefined
}
}

async function openDevContainer(
Expand Down
65 changes: 37 additions & 28 deletions src/remote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isAxiosError } from "axios"
import { Api } from "coder/site/src/api/api"
import { Workspace } from "coder/site/src/api/typesGenerated"
import { SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated"
import find from "find-process"
import * as fs from "fs/promises"
import * as jsonc from "jsonc-parser"
Expand All @@ -9,7 +9,14 @@ import * as path from "path"
import prettyBytes from "pretty-bytes"
import * as semver from "semver"
import * as vscode from "vscode"
import { createHttpAgent, makeCoderSdk, needToken, startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api"
import {
createHttpAgent,
fetchSSHConfig,
makeCoderSdk,
needToken,
startWorkspaceIfStoppedOrFailed,
waitForBuild,
} from "./api"
import { extractAgents } from "./api-helper"
import * as cli from "./cliManager"
import { Commands } from "./commands"
Expand All @@ -19,7 +26,7 @@ import { Inbox } from "./inbox"
import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"
import { Storage } from "./storage"
import { AuthorityPrefix, expandPath, parseRemoteAuthority } from "./util"
import { AuthorityPrefix, expandPath, maybeCoderConnectAddr, parseRemoteAuthority } from "./util"
import { WorkspaceMonitor } from "./workspaceMonitor"

export interface RemoteDetails extends vscode.Disposable {
Expand Down Expand Up @@ -469,9 +476,19 @@ export class Remote {
//
// If we didn't write to the SSH config file, connecting would fail with
// "Host not found".
let sshConfigResponse: SSHConfigResponse
try {
this.storage.writeToCoderOutputChannel("Updating SSH config...")
await this.updateSSHConfig(workspaceRestClient, parts.label, parts.host, binaryPath, logDir, featureSet)
sshConfigResponse = await fetchSSHConfig(workspaceRestClient, this.vscodeProposed)
await this.updateSSHConfig(
workspaceRestClient,
parts.label,
parts.host,
binaryPath,
logDir,
featureSet,
sshConfigResponse,
)
} catch (error) {
this.storage.writeToCoderOutputChannel(`Failed to configure SSH: ${error}`)
throw error
Expand Down Expand Up @@ -503,6 +520,20 @@ export class Remote {

this.storage.writeToCoderOutputChannel("Remote setup complete")

// If Coder Connect is available for this workspace, switch to that
const coderConnectAddr = await maybeCoderConnectAddr(
agent.name,
parts.workspace,
parts.username,
sshConfigResponse.hostname_suffix,
)
if (coderConnectAddr) {
await vscode.commands.executeCommand("vscode.newWindow", {
remoteAuthority: `ssh-remote+${coderConnectAddr}`,
reuseWindow: true,
})
}

// Returning the URL and token allows the plugin to authenticate its own
// client, for example to display the list of workspaces belonging to this
// deployment in the sidebar. We use our own client in here for reasons
Expand Down Expand Up @@ -550,30 +581,8 @@ export class Remote {
binaryPath: string,
logDir: string,
featureSet: FeatureSet,
sshConfigResponse: SSHConfigResponse,
) {
let deploymentSSHConfig = {}
try {
const deploymentConfig = await restClient.getDeploymentSSHConfig()
deploymentSSHConfig = deploymentConfig.ssh_config_options
} catch (error) {
if (!isAxiosError(error)) {
throw error
}
switch (error.response?.status) {
case 404: {
// Deployment does not support overriding ssh config yet. Likely an
// older version, just use the default.
break
}
case 401: {
await this.vscodeProposed.window.showErrorMessage("Your session expired...")
throw error
}
default:
throw error
}
}

// deploymentConfig is now set from the remote coderd deployment.
// Now override with the user's config.
const userConfigSSH = vscode.workspace.getConfiguration("coder").get<string[]>("sshConfig") || []
Expand All @@ -596,7 +605,7 @@ export class Remote {
},
{} as Record<string, string>,
)
const sshConfigOverrides = mergeSSHConfigValues(deploymentSSHConfig, userConfig)
const sshConfigOverrides = mergeSSHConfigValues(sshConfigResponse.ssh_config_options, userConfig)

let sshConfigFile = vscode.workspace.getConfiguration().get<string>("remote.SSH.configFile")
if (!sshConfigFile) {
Expand Down
20 changes: 20 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { lookup } from "dns"
import ipRangeCheck from "ip-range-check"
import * as os from "os"
import url from "url"
import { promisify } from "util"

export interface AuthorityParts {
agent: string | undefined
Expand Down Expand Up @@ -61,6 +64,23 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
}
}

export async function maybeCoderConnectAddr(
agent: string,
workspace: string,
owner: string,
hostnameSuffix: string,
): Promise<string | undefined> {
const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}`
try {
const res = await promisify(lookup)(coderConnectHostname)
// Captive DNS portals may return an unrelated address, so we check it's
// within the Coder Service Prefix.
return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined
} catch {
return undefined
}
}

export function toRemoteAuthority(
baseUrl: string,
workspaceOwner: string,
Expand Down