Skip to content

Commit 3dfa588

Browse files
authored
feat: Add feature to allow config-ssh value overrides (#69)
1 parent 4e471cb commit 3dfa588

File tree

6 files changed

+153
-25
lines changed

6 files changed

+153
-25
lines changed

src/commands.ts

+23-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import axios from "axios"
2-
import { getUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api"
2+
import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api"
33
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
44
import * as vscode from "vscode"
55
import { Remote } from "./remote"
@@ -73,21 +73,28 @@ export class Commands {
7373

7474
await this.storage.setURL(url)
7575
await this.storage.setSessionToken(token)
76-
const user = await getUser()
77-
await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
78-
vscode.window
79-
.showInformationMessage(
80-
`Welcome to Coder, ${user.username}!`,
81-
{
82-
detail: "You can now use the Coder extension to manage your Coder instance.",
83-
},
84-
"Open Workspace",
85-
)
86-
.then((action) => {
87-
if (action === "Open Workspace") {
88-
vscode.commands.executeCommand("coder.open")
89-
}
90-
})
76+
try {
77+
const user = await getAuthenticatedUser()
78+
if (!user) {
79+
throw new Error("Failed to get authenticated user")
80+
}
81+
await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
82+
vscode.window
83+
.showInformationMessage(
84+
`Welcome to Coder, ${user.username}!`,
85+
{
86+
detail: "You can now use the Coder extension to manage your Coder instance.",
87+
},
88+
"Open Workspace",
89+
)
90+
.then((action) => {
91+
if (action === "Open Workspace") {
92+
vscode.commands.executeCommand("coder.open")
93+
}
94+
})
95+
} catch (error) {
96+
vscode.window.showErrorMessage("Failed to authenticate with Coder: " + error)
97+
}
9198
}
9299

93100
public async logout(): Promise<void> {

src/extension.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use strict"
22

3-
import { getUser } from "coder/site/src/api/api"
3+
import { getAuthenticatedUser } from "coder/site/src/api/api"
44
import * as module from "module"
55
import * as vscode from "vscode"
66
import { Commands } from "./commands"
@@ -12,7 +12,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
1212
const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri)
1313
await storage.init()
1414

15-
getUser()
15+
getAuthenticatedUser()
1616
.then(() => {
1717
vscode.commands.executeCommand("setContext", "coder.authenticated", true)
1818
})

src/remote.ts

+27-3
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
getWorkspaceBuildLogs,
77
getWorkspaceByOwnerAndName,
88
startWorkspace,
9+
getDeploymentSSHConfig,
910
} from "coder/site/src/api/api"
10-
import { ProvisionerJobLog, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
11+
import { ProvisionerJobLog, SSHConfigResponse, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
1112
import EventSource from "eventsource"
1213
import find from "find-process"
1314
import * as fs from "fs/promises"
@@ -18,7 +19,7 @@ import prettyBytes from "pretty-bytes"
1819
import * as semver from "semver"
1920
import * as vscode from "vscode"
2021
import * as ws from "ws"
21-
import { SSHConfig } from "./sshConfig"
22+
import { SSHConfig, defaultSSHConfigResponse } from "./sshConfig"
2223
import { Storage } from "./storage"
2324

2425
export class Remote {
@@ -440,6 +441,29 @@ export class Remote {
440441
// updateSSHConfig updates the SSH configuration with a wildcard that handles
441442
// all Coder entries.
442443
private async updateSSHConfig() {
444+
let deploymentConfig: SSHConfigResponse = defaultSSHConfigResponse
445+
try {
446+
deploymentConfig = await getDeploymentSSHConfig()
447+
} catch (error) {
448+
if (!axios.isAxiosError(error)) {
449+
throw error
450+
}
451+
switch (error.response?.status) {
452+
case 404: {
453+
// Deployment does not support overriding ssh config yet. Likely an
454+
// older version, just use the default.
455+
deploymentConfig = defaultSSHConfigResponse
456+
break
457+
}
458+
case 401: {
459+
await this.vscodeProposed.window.showErrorMessage("Your session expired...")
460+
throw error
461+
}
462+
default:
463+
throw error
464+
}
465+
}
466+
443467
let sshConfigFile = vscode.workspace.getConfiguration().get<string>("remote.SSH.configFile")
444468
if (!sshConfigFile) {
445469
sshConfigFile = path.join(os.homedir(), ".ssh", "config")
@@ -480,7 +504,7 @@ export class Remote {
480504
SetEnv: "CODER_SSH_SESSION_TYPE=vscode",
481505
}
482506

483-
await sshConfig.update(sshValues)
507+
await sshConfig.update(sshValues, deploymentConfig)
484508
}
485509

486510
// showNetworkUpdates finds the SSH process ID that is being used by this

src/sshConfig.test.ts

+43
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,46 @@ Host coder-vscode--*
167167
mode: 384,
168168
})
169169
})
170+
171+
it("override values", async () => {
172+
mockFileSystem.readFile.mockRejectedValueOnce("No file found")
173+
const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
174+
await sshConfig.load()
175+
await sshConfig.update(
176+
{
177+
Host: "coder-vscode--*",
178+
ProxyCommand: "some-command-here",
179+
ConnectTimeout: "0",
180+
StrictHostKeyChecking: "no",
181+
UserKnownHostsFile: "/dev/null",
182+
LogLevel: "ERROR",
183+
},
184+
{
185+
ssh_config_options: {
186+
loglevel: "DEBUG", // This tests case insensitive
187+
ConnectTimeout: "500",
188+
ExtraKey: "ExtraValue",
189+
Foo: "bar",
190+
Buzz: "baz",
191+
// Remove this key
192+
StrictHostKeyChecking: "",
193+
ExtraRemove: "",
194+
},
195+
hostname_prefix: "",
196+
},
197+
)
198+
199+
const expectedOutput = `# --- START CODER VSCODE ---
200+
Host coder-vscode--*
201+
ProxyCommand some-command-here
202+
ConnectTimeout 500
203+
UserKnownHostsFile /dev/null
204+
LogLevel DEBUG
205+
Buzz baz
206+
ExtraKey ExtraValue
207+
Foo bar
208+
# --- END CODER VSCODE ---`
209+
210+
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
211+
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything())
212+
})

src/sshConfig.ts

+57-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SSHConfigResponse } from "coder/site/src/api/typesGenerated"
12
import { writeFile, readFile } from "fs/promises"
23
import { ensureDir } from "fs-extra"
34
import path from "path"
@@ -30,6 +31,12 @@ const defaultFileSystem: FileSystem = {
3031
writeFile,
3132
}
3233

34+
export const defaultSSHConfigResponse: SSHConfigResponse = {
35+
ssh_config_options: {},
36+
// The prefix is not used by the vscode-extension
37+
hostname_prefix: "coder.",
38+
}
39+
3340
export class SSHConfig {
3441
private filePath: string
3542
private fileSystem: FileSystem
@@ -51,15 +58,15 @@ export class SSHConfig {
5158
}
5259
}
5360

54-
async update(values: SSHValues) {
61+
async update(values: SSHValues, overrides: SSHConfigResponse = defaultSSHConfigResponse) {
5562
// We should remove this in March 2023 because there is not going to have
5663
// old configs
5764
this.cleanUpOldConfig()
5865
const block = this.getBlock()
5966
if (block) {
6067
this.eraseBlock(block)
6168
}
62-
this.appendBlock(values)
69+
this.appendBlock(values, overrides.ssh_config_options)
6370
await this.save()
6471
}
6572

@@ -102,12 +109,59 @@ export class SSHConfig {
102109
this.raw = this.getRaw().replace(block.raw, "")
103110
}
104111

105-
private appendBlock({ Host, ...otherValues }: SSHValues) {
112+
/**
113+
*
114+
* appendBlock builds the ssh config block. The order of the keys is determinstic based on the input.
115+
* Expected values are always in a consistent order followed by any additional overrides in sorted order.
116+
*
117+
* @param param0 - SSHValues are the expected SSH values for using ssh with coder.
118+
* @param overrides - Overrides typically come from the deployment api and are used to override the default values.
119+
* The overrides are given as key:value pairs where the key is the ssh config file key.
120+
* If the key matches an expected value, the expected value is overridden. If it does not
121+
* match an expected value, it is appended to the end of the block.
122+
*/
123+
private appendBlock({ Host, ...otherValues }: SSHValues, overrides: Record<string, string>) {
106124
const lines = [this.startBlockComment, `Host ${Host}`]
125+
// We need to do a case insensitive match for the overrides as ssh config keys are case insensitive.
126+
// To get the correct key:value, use:
127+
// key = caseInsensitiveOverrides[key.toLowerCase()]
128+
// value = overrides[key]
129+
const caseInsensitiveOverrides: Record<string, string> = {}
130+
Object.keys(overrides).forEach((key) => {
131+
caseInsensitiveOverrides[key.toLowerCase()] = key
132+
})
133+
107134
const keys = Object.keys(otherValues) as Array<keyof typeof otherValues>
108135
keys.forEach((key) => {
136+
const lower = key.toLowerCase()
137+
if (caseInsensitiveOverrides[lower]) {
138+
const correctCaseKey = caseInsensitiveOverrides[lower]
139+
const value = overrides[correctCaseKey]
140+
// Remove the key from the overrides so we don't write it again.
141+
delete caseInsensitiveOverrides[lower]
142+
if (value === "") {
143+
// If the value is empty, don't write it. Prevent writing the default
144+
// value as well.
145+
return
146+
}
147+
// If the key is in overrides, use the override value.
148+
// Doing it this way maintains the default order of the keys.
149+
lines.push(this.withIndentation(`${key} ${value}`))
150+
return
151+
}
109152
lines.push(this.withIndentation(`${key} ${otherValues[key]}`))
110153
})
154+
// Write remaining overrides that have not been written yet. Sort to maintain deterministic order.
155+
const remainingKeys = (Object.keys(caseInsensitiveOverrides) as Array<keyof typeof caseInsensitiveOverrides>).sort()
156+
remainingKeys.forEach((key) => {
157+
const correctKey = caseInsensitiveOverrides[key]
158+
const value = overrides[correctKey]
159+
// Only write the value if it is not empty.
160+
if (value !== "") {
161+
lines.push(this.withIndentation(`${correctKey} ${value}`))
162+
}
163+
})
164+
111165
lines.push(this.endBlockComment)
112166
const raw = this.getRaw()
113167

yarn.lock

+1-1
Original file line numberDiff line numberDiff line change
@@ -1418,7 +1418,7 @@ [email protected]:
14181418

14191419
"coder@https://github.com/coder/coder":
14201420
version "0.0.0"
1421-
resolved "https://github.com/coder/coder#7a1731b6205d9c68f6308ee362ff2d62124b6950"
1421+
resolved "https://github.com/coder/coder#a6fa8cac582f2fc54eca0191bd54fd43d6d67ac2"
14221422

14231423
collapse-white-space@^1.0.2:
14241424
version "1.0.6"

0 commit comments

Comments
 (0)