Skip to content

Commit 3e9cfec

Browse files
authored
feat: Add extension settings for customizing ssh config (#74)
1 parent 6babe59 commit 3e9cfec

File tree

4 files changed

+131
-80
lines changed

4 files changed

+131
-80
lines changed

package.json

+17-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@
2929
],
3030
"main": "./dist/extension.js",
3131
"contributes": {
32+
"configuration": {
33+
"title": "Coder",
34+
"properties": {
35+
"coder.sshConfig": {
36+
"markdownDescription": "These values will be included in the ssh config file. Eg: `'ConnectTimeout=10'` will set the timeout to 10 seconds. Any values included here will override anything provided by default or by the deployment. To unset a value that is written by default, set the value to the empty string, Eg: `'ConnectTimeout='` will unset it.",
37+
"type": "array",
38+
"items": {
39+
"title": "SSH Config Value",
40+
"type": "string",
41+
"pattern": "^[a-zA-Z0-9-]+[=\\s].*$"
42+
},
43+
"scope": "machine",
44+
"default": []
45+
}
46+
}
47+
},
3248
"viewsContainers": {
3349
"activitybar": [
3450
{
@@ -140,4 +156,4 @@
140156
"ws": "^8.11.0",
141157
"yaml": "^1.10.0"
142158
}
143-
}
159+
}

src/remote.ts

+27-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
startWorkspace,
99
getDeploymentSSHConfig,
1010
} from "coder/site/src/api/api"
11-
import { ProvisionerJobLog, SSHConfigResponse, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
11+
import { ProvisionerJobLog, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
1212
import EventSource from "eventsource"
1313
import find from "find-process"
1414
import * as fs from "fs/promises"
@@ -19,7 +19,7 @@ import prettyBytes from "pretty-bytes"
1919
import * as semver from "semver"
2020
import * as vscode from "vscode"
2121
import * as ws from "ws"
22-
import { SSHConfig, defaultSSHConfigResponse } from "./sshConfig"
22+
import { SSHConfig, defaultSSHConfigResponse, mergeSSHConfigValues } from "./sshConfig"
2323
import { Storage } from "./storage"
2424

2525
export class Remote {
@@ -441,9 +441,10 @@ export class Remote {
441441
// updateSSHConfig updates the SSH configuration with a wildcard that handles
442442
// all Coder entries.
443443
private async updateSSHConfig() {
444-
let deploymentConfig: SSHConfigResponse = defaultSSHConfigResponse
444+
let deploymentSSHConfig = defaultSSHConfigResponse
445445
try {
446-
deploymentConfig = await getDeploymentSSHConfig()
446+
const deploymentConfig = await getDeploymentSSHConfig()
447+
deploymentSSHConfig = deploymentConfig.ssh_config_options
447448
} catch (error) {
448449
if (!axios.isAxiosError(error)) {
449450
throw error
@@ -452,7 +453,6 @@ export class Remote {
452453
case 404: {
453454
// Deployment does not support overriding ssh config yet. Likely an
454455
// older version, just use the default.
455-
deploymentConfig = defaultSSHConfigResponse
456456
break
457457
}
458458
case 401: {
@@ -464,6 +464,27 @@ export class Remote {
464464
}
465465
}
466466

467+
// deploymentConfig is now set from the remote coderd deployment.
468+
// Now override with the user's config.
469+
const userConfigSSH = vscode.workspace.getConfiguration("coder").get<string[]>("sshConfig") || []
470+
// Parse the user's config into a Record<string, string>.
471+
const userConfig = userConfigSSH.reduce((acc, line) => {
472+
let i = line.indexOf("=")
473+
if (i === -1) {
474+
i = line.indexOf(" ")
475+
if (i === -1) {
476+
// This line is malformed. The setting is incorrect, and does not match
477+
// the pattern regex in the settings schema.
478+
return acc
479+
}
480+
}
481+
const key = line.slice(0, i)
482+
const value = line.slice(i + 1)
483+
acc[key] = value
484+
return acc
485+
}, {} as Record<string, string>)
486+
const sshConfigOverrides = mergeSSHConfigValues(deploymentSSHConfig, userConfig)
487+
467488
let sshConfigFile = vscode.workspace.getConfiguration().get<string>("remote.SSH.configFile")
468489
if (!sshConfigFile) {
469490
sshConfigFile = path.join(os.homedir(), ".ssh", "config")
@@ -504,7 +525,7 @@ export class Remote {
504525
SetEnv: "CODER_SSH_SESSION_TYPE=vscode",
505526
}
506527

507-
await sshConfig.update(sshValues, deploymentConfig)
528+
await sshConfig.update(sshValues, sshConfigOverrides)
508529
}
509530

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

src/sshConfig.test.ts

+30-33
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ it("creates a new file and adds the config", async () => {
3030

3131
const expectedOutput = `# --- START CODER VSCODE ---
3232
Host coder-vscode--*
33-
ProxyCommand some-command-here
3433
ConnectTimeout 0
34+
LogLevel ERROR
35+
ProxyCommand some-command-here
3536
StrictHostKeyChecking no
3637
UserKnownHostsFile /dev/null
37-
LogLevel ERROR
3838
# --- END CODER VSCODE ---`
3939

4040
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
@@ -43,12 +43,12 @@ Host coder-vscode--*
4343

4444
it("adds a new coder config in an existent SSH configuration", async () => {
4545
const existentSSHConfig = `Host coder.something
46-
HostName coder.something
4746
ConnectTimeout=0
48-
StrictHostKeyChecking=no
49-
UserKnownHostsFile=/dev/null
5047
LogLevel ERROR
51-
ProxyCommand command`
48+
HostName coder.something
49+
ProxyCommand command
50+
StrictHostKeyChecking=no
51+
UserKnownHostsFile=/dev/null`
5252
mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig)
5353

5454
const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
@@ -66,11 +66,11 @@ it("adds a new coder config in an existent SSH configuration", async () => {
6666
6767
# --- START CODER VSCODE ---
6868
Host coder-vscode--*
69-
ProxyCommand some-command-here
7069
ConnectTimeout 0
70+
LogLevel ERROR
71+
ProxyCommand some-command-here
7172
StrictHostKeyChecking no
7273
UserKnownHostsFile /dev/null
73-
LogLevel ERROR
7474
# --- END CODER VSCODE ---`
7575

7676
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
@@ -90,11 +90,11 @@ it("updates an existent coder config", async () => {
9090
9191
# --- START CODER VSCODE ---
9292
Host coder-vscode--*
93-
ProxyCommand some-command-here
9493
ConnectTimeout 0
94+
LogLevel ERROR
95+
ProxyCommand some-command-here
9596
StrictHostKeyChecking no
9697
UserKnownHostsFile /dev/null
97-
LogLevel ERROR
9898
# --- END CODER VSCODE ---`
9999
mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig)
100100

@@ -119,11 +119,11 @@ Host coder-vscode--*
119119
120120
# --- START CODER VSCODE ---
121121
Host coder--updated--vscode--*
122-
ProxyCommand some-command-here
123122
ConnectTimeout 0
123+
LogLevel ERROR
124+
ProxyCommand some-command-here
124125
StrictHostKeyChecking no
125126
UserKnownHostsFile /dev/null
126-
LogLevel ERROR
127127
# --- END CODER VSCODE ---`
128128

129129
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
@@ -134,12 +134,12 @@ Host coder--updated--vscode--*
134134

135135
it("removes old coder SSH config and adds the new one", async () => {
136136
const existentSSHConfig = `Host coder-vscode--*
137-
HostName coder.something
138137
ConnectTimeout=0
139-
StrictHostKeyChecking=no
140-
UserKnownHostsFile=/dev/null
138+
HostName coder.something
141139
LogLevel ERROR
142-
ProxyCommand command`
140+
ProxyCommand command
141+
StrictHostKeyChecking=no
142+
UserKnownHostsFile=/dev/null`
143143
mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig)
144144

145145
const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
@@ -155,11 +155,11 @@ it("removes old coder SSH config and adds the new one", async () => {
155155

156156
const expectedOutput = `# --- START CODER VSCODE ---
157157
Host coder-vscode--*
158-
ProxyCommand some-command-here
159158
ConnectTimeout 0
159+
LogLevel ERROR
160+
ProxyCommand some-command-here
160161
StrictHostKeyChecking no
161162
UserKnownHostsFile /dev/null
162-
LogLevel ERROR
163163
# --- END CODER VSCODE ---`
164164

165165
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
@@ -182,29 +182,26 @@ it("override values", async () => {
182182
LogLevel: "ERROR",
183183
},
184184
{
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: "",
185+
loglevel: "DEBUG", // This tests case insensitive
186+
ConnectTimeout: "500",
187+
ExtraKey: "ExtraValue",
188+
Foo: "bar",
189+
Buzz: "baz",
190+
// Remove this key
191+
StrictHostKeyChecking: "",
192+
ExtraRemove: "",
196193
},
197194
)
198195

199196
const expectedOutput = `# --- START CODER VSCODE ---
200197
Host coder-vscode--*
201-
ProxyCommand some-command-here
202-
ConnectTimeout 500
203-
UserKnownHostsFile /dev/null
204-
LogLevel DEBUG
205198
Buzz baz
199+
ConnectTimeout 500
206200
ExtraKey ExtraValue
207201
Foo bar
202+
ProxyCommand some-command-here
203+
UserKnownHostsFile /dev/null
204+
loglevel DEBUG
208205
# --- END CODER VSCODE ---`
209206

210207
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())

src/sshConfig.ts

+57-40
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,54 @@ const defaultFileSystem: FileSystem = {
3131
writeFile,
3232
}
3333

34-
export const defaultSSHConfigResponse: SSHConfigResponse = {
35-
ssh_config_options: {},
36-
// The prefix is not used by the vscode-extension
37-
hostname_prefix: "coder.",
34+
export const defaultSSHConfigResponse: Record<string, string> = {}
35+
36+
// mergeSSHConfigValues will take a given ssh config and merge it with the overrides
37+
// provided. The merge handles key case insensitivity, so casing in the "key" does
38+
// not matter.
39+
export function mergeSSHConfigValues(
40+
config: Record<string, string>,
41+
overrides: Record<string, string>,
42+
): Record<string, string> {
43+
const merged: Record<string, string> = {}
44+
45+
// We need to do a case insensitive match for the overrides as ssh config keys are case insensitive.
46+
// To get the correct key:value, use:
47+
// key = caseInsensitiveOverrides[key.toLowerCase()]
48+
// value = overrides[key]
49+
const caseInsensitiveOverrides: Record<string, string> = {}
50+
Object.keys(overrides).forEach((key) => {
51+
caseInsensitiveOverrides[key.toLowerCase()] = key
52+
})
53+
54+
Object.keys(config).forEach((key) => {
55+
const lower = key.toLowerCase()
56+
// If the key is in overrides, use the override value.
57+
if (caseInsensitiveOverrides[lower]) {
58+
const correctCaseKey = caseInsensitiveOverrides[lower]
59+
const value = overrides[correctCaseKey]
60+
delete caseInsensitiveOverrides[lower]
61+
62+
// If the value is empty, do not add the key. It is being removed.
63+
if (value === "") {
64+
return
65+
}
66+
merged[correctCaseKey] = value
67+
return
68+
}
69+
// If no override, take the original value.
70+
if (config[key] !== "") {
71+
merged[key] = config[key]
72+
}
73+
})
74+
75+
// Add remaining overrides.
76+
Object.keys(caseInsensitiveOverrides).forEach((lower) => {
77+
const correctCaseKey = caseInsensitiveOverrides[lower]
78+
merged[correctCaseKey] = overrides[correctCaseKey]
79+
})
80+
81+
return merged
3882
}
3983

4084
export class SSHConfig {
@@ -58,15 +102,15 @@ export class SSHConfig {
58102
}
59103
}
60104

61-
async update(values: SSHValues, overrides: SSHConfigResponse = defaultSSHConfigResponse) {
105+
async update(values: SSHValues, overrides: Record<string, string> = defaultSSHConfigResponse) {
62106
// We should remove this in March 2023 because there is not going to have
63107
// old configs
64108
this.cleanUpOldConfig()
65109
const block = this.getBlock()
66110
if (block) {
67111
this.eraseBlock(block)
68112
}
69-
this.appendBlock(values, overrides.ssh_config_options)
113+
this.appendBlock(values, overrides)
70114
await this.save()
71115
}
72116

@@ -122,43 +166,16 @@ export class SSHConfig {
122166
*/
123167
private appendBlock({ Host, ...otherValues }: SSHValues, overrides: Record<string, string>) {
124168
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-
})
133169

134-
const keys = Object.keys(otherValues) as Array<keyof typeof otherValues>
170+
// configValues is the merged values of the defaults and the overrides.
171+
const configValues = mergeSSHConfigValues(otherValues, overrides)
172+
173+
// keys is the sorted keys of the merged values.
174+
const keys = (Object.keys(configValues) as Array<keyof typeof configValues>).sort()
135175
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-
}
152-
lines.push(this.withIndentation(`${key} ${otherValues[key]}`))
153-
})
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.
176+
const value = configValues[key]
160177
if (value !== "") {
161-
lines.push(this.withIndentation(`${correctKey} ${value}`))
178+
lines.push(this.withIndentation(`${key} ${value}`))
162179
}
163180
})
164181

0 commit comments

Comments
 (0)