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

Lines changed: 17 additions & 1 deletion
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

Lines changed: 27 additions & 6 deletions
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

Lines changed: 30 additions & 33 deletions
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

Lines changed: 57 additions & 40 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy