Skip to content

Commit 195151a

Browse files
committed
switch to coder connect dynamically
1 parent 9252fff commit 195151a

File tree

4 files changed

+96
-76
lines changed

4 files changed

+96
-76
lines changed

src/api.ts

+33-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { AxiosInstance } from "axios"
1+
import { AxiosInstance, isAxiosError } from "axios"
22
import { spawn } from "child_process"
33
import { Api } from "coder/site/src/api/api"
4-
import { ProvisionerJobLog, Workspace } from "coder/site/src/api/typesGenerated"
4+
import { ProvisionerJobLog, SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated"
55
import { FetchLikeInit } from "eventsource"
66
import fs from "fs/promises"
77
import { ProxyAgent } from "proxy-agent"
@@ -280,3 +280,34 @@ export async function waitForBuild(
280280
writeEmitter.fire(`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`)
281281
return updatedWorkspace
282282
}
283+
284+
export async function fetchSSHConfig(restClient: Api, vsc: typeof vscode): Promise<SSHConfigResponse> {
285+
try {
286+
const sshConfig = await restClient.getDeploymentSSHConfig()
287+
return {
288+
hostname_prefix: sshConfig.hostname_prefix,
289+
hostname_suffix: sshConfig.hostname_suffix ?? "coder",
290+
ssh_config_options: sshConfig.ssh_config_options,
291+
}
292+
} catch (error) {
293+
if (!isAxiosError(error)) {
294+
throw error
295+
}
296+
switch (error.response?.status) {
297+
case 404: {
298+
// Very old deployment that doesn't support SSH config
299+
return {
300+
hostname_prefix: "coder",
301+
hostname_suffix: "coder",
302+
ssh_config_options: {},
303+
}
304+
}
305+
case 401: {
306+
vsc.window.showErrorMessage("Your session expired...")
307+
throw error
308+
}
309+
default:
310+
throw error
311+
}
312+
}
313+
}

src/commands.ts

+6-46
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import { isAxiosError } from "axios"
21
import { Api } from "coder/site/src/api/api"
32
import { getErrorMessage } from "coder/site/src/api/errors"
43
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
5-
import { lookup } from "dns"
6-
import ipRangeCheck from "ip-range-check"
7-
import { promisify } from "util"
84
import * as vscode from "vscode"
9-
import { makeCoderSdk, needToken } from "./api"
5+
import { fetchSSHConfig, makeCoderSdk, needToken } from "./api"
106
import { extractAgents } from "./api-helper"
117
import { CertificateError } from "./error"
128
import { Storage } from "./storage"
13-
import { toRemoteAuthority, toSafeHost } from "./util"
9+
import { maybeCoderConnectAddr, toRemoteAuthority, toSafeHost } from "./util"
1410
import { OpenableTreeItem } from "./workspacesProvider"
1511

1612
export class Commands {
@@ -573,10 +569,10 @@ export class Commands {
573569
// if the workspace is stopped, in which case we can't use Coder Connect
574570
// When called from `open`, the workspaceAgent will always be set.
575571
if (workspaceAgent) {
576-
let hostnameSuffix = "coder"
572+
let sshConfig
577573
try {
578-
// If the field was undefined, it's an older server, and always 'coder'
579-
hostnameSuffix = (await this.fetchHostnameSuffix()) ?? hostnameSuffix
574+
// Fetch (or get defaults) for the SSH config.
575+
sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed)
580576
} catch (error) {
581577
const message = getErrorMessage(error, "no response from the server")
582578
this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`)
@@ -592,7 +588,7 @@ export class Commands {
592588
workspaceAgent,
593589
workspaceName,
594590
workspaceOwner,
595-
hostnameSuffix,
591+
sshConfig.hostname_suffix,
596592
)
597593
if (coderConnectAddr) {
598594
remoteAuthority = `ssh-remote+${coderConnectAddr}`
@@ -656,42 +652,6 @@ export class Commands {
656652
reuseWindow: !newWindow,
657653
})
658654
}
659-
660-
private async fetchHostnameSuffix(): Promise<string | undefined> {
661-
try {
662-
const sshConfig = await this.restClient.getDeploymentSSHConfig()
663-
return sshConfig.hostname_suffix
664-
} catch (error) {
665-
if (!isAxiosError(error)) {
666-
throw error
667-
}
668-
switch (error.response?.status) {
669-
case 404: {
670-
// Likely a very old deployment, just use the default.
671-
break
672-
}
673-
default:
674-
throw error
675-
}
676-
}
677-
}
678-
}
679-
680-
async function maybeCoderConnectAddr(
681-
agent: string,
682-
workspace: string,
683-
owner: string,
684-
hostnameSuffix: string,
685-
): Promise<string | undefined> {
686-
const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}`
687-
try {
688-
const res = await promisify(lookup)(coderConnectHostname)
689-
// Captive DNS portals may return an unrelated address, so we check it's
690-
// within the Coder Service Prefix.
691-
return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined
692-
} catch {
693-
return undefined
694-
}
695655
}
696656

697657
async function openDevContainer(

src/remote.ts

+37-28
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { isAxiosError } from "axios"
22
import { Api } from "coder/site/src/api/api"
3-
import { Workspace } from "coder/site/src/api/typesGenerated"
3+
import { SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated"
44
import find from "find-process"
55
import * as fs from "fs/promises"
66
import * as jsonc from "jsonc-parser"
@@ -9,7 +9,14 @@ import * as path from "path"
99
import prettyBytes from "pretty-bytes"
1010
import * as semver from "semver"
1111
import * as vscode from "vscode"
12-
import { createHttpAgent, makeCoderSdk, needToken, startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api"
12+
import {
13+
createHttpAgent,
14+
fetchSSHConfig,
15+
makeCoderSdk,
16+
needToken,
17+
startWorkspaceIfStoppedOrFailed,
18+
waitForBuild,
19+
} from "./api"
1320
import { extractAgents } from "./api-helper"
1421
import * as cli from "./cliManager"
1522
import { Commands } from "./commands"
@@ -19,7 +26,7 @@ import { Inbox } from "./inbox"
1926
import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"
2027
import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"
2128
import { Storage } from "./storage"
22-
import { AuthorityPrefix, expandPath, parseRemoteAuthority } from "./util"
29+
import { AuthorityPrefix, expandPath, maybeCoderConnectAddr, parseRemoteAuthority } from "./util"
2330
import { WorkspaceMonitor } from "./workspaceMonitor"
2431

2532
export interface RemoteDetails extends vscode.Disposable {
@@ -469,9 +476,19 @@ export class Remote {
469476
//
470477
// If we didn't write to the SSH config file, connecting would fail with
471478
// "Host not found".
479+
let sshConfigResponse: SSHConfigResponse
472480
try {
473481
this.storage.writeToCoderOutputChannel("Updating SSH config...")
474-
await this.updateSSHConfig(workspaceRestClient, parts.label, parts.host, binaryPath, logDir, featureSet)
482+
sshConfigResponse = await fetchSSHConfig(workspaceRestClient, this.vscodeProposed)
483+
await this.updateSSHConfig(
484+
workspaceRestClient,
485+
parts.label,
486+
parts.host,
487+
binaryPath,
488+
logDir,
489+
featureSet,
490+
sshConfigResponse,
491+
)
475492
} catch (error) {
476493
this.storage.writeToCoderOutputChannel(`Failed to configure SSH: ${error}`)
477494
throw error
@@ -503,6 +520,20 @@ export class Remote {
503520

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

523+
// If Coder Connect is available for this workspace, switch to that
524+
const coderConnectAddr = await maybeCoderConnectAddr(
525+
agent.name,
526+
parts.workspace,
527+
parts.username,
528+
sshConfigResponse.hostname_suffix,
529+
)
530+
if (coderConnectAddr) {
531+
await vscode.commands.executeCommand("vscode.newWindow", {
532+
remoteAuthority: `ssh-remote+${coderConnectAddr}`,
533+
reuseWindow: true,
534+
})
535+
}
536+
506537
// Returning the URL and token allows the plugin to authenticate its own
507538
// client, for example to display the list of workspaces belonging to this
508539
// deployment in the sidebar. We use our own client in here for reasons
@@ -550,30 +581,8 @@ export class Remote {
550581
binaryPath: string,
551582
logDir: string,
552583
featureSet: FeatureSet,
584+
sshConfigResponse: SSHConfigResponse,
553585
) {
554-
let deploymentSSHConfig = {}
555-
try {
556-
const deploymentConfig = await restClient.getDeploymentSSHConfig()
557-
deploymentSSHConfig = deploymentConfig.ssh_config_options
558-
} catch (error) {
559-
if (!isAxiosError(error)) {
560-
throw error
561-
}
562-
switch (error.response?.status) {
563-
case 404: {
564-
// Deployment does not support overriding ssh config yet. Likely an
565-
// older version, just use the default.
566-
break
567-
}
568-
case 401: {
569-
await this.vscodeProposed.window.showErrorMessage("Your session expired...")
570-
throw error
571-
}
572-
default:
573-
throw error
574-
}
575-
}
576-
577586
// deploymentConfig is now set from the remote coderd deployment.
578587
// Now override with the user's config.
579588
const userConfigSSH = vscode.workspace.getConfiguration("coder").get<string[]>("sshConfig") || []
@@ -596,7 +605,7 @@ export class Remote {
596605
},
597606
{} as Record<string, string>,
598607
)
599-
const sshConfigOverrides = mergeSSHConfigValues(deploymentSSHConfig, userConfig)
608+
const sshConfigOverrides = mergeSSHConfigValues(sshConfigResponse.ssh_config_options, userConfig)
600609

601610
let sshConfigFile = vscode.workspace.getConfiguration().get<string>("remote.SSH.configFile")
602611
if (!sshConfigFile) {

src/util.ts

+20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { lookup } from "dns"
2+
import ipRangeCheck from "ip-range-check"
13
import * as os from "os"
24
import url from "url"
5+
import { promisify } from "util"
36

47
export interface AuthorityParts {
58
agent: string | undefined
@@ -61,6 +64,23 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
6164
}
6265
}
6366

67+
export async function maybeCoderConnectAddr(
68+
agent: string,
69+
workspace: string,
70+
owner: string,
71+
hostnameSuffix: string,
72+
): Promise<string | undefined> {
73+
const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}`
74+
try {
75+
const res = await promisify(lookup)(coderConnectHostname)
76+
// Captive DNS portals may return an unrelated address, so we check it's
77+
// within the Coder Service Prefix.
78+
return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined
79+
} catch {
80+
return undefined
81+
}
82+
}
83+
6484
export function toRemoteAuthority(
6585
baseUrl: string,
6686
workspaceOwner: string,

0 commit comments

Comments
 (0)