Skip to content

Commit 216491f

Browse files
committed
fix: include our own SSH config from the top of the user's
SSH uses the first value it obtains for each option, so a catch-all "Host *" in the user's config beat the block we appended to the end of it, and connections aborted with "Unexpected SSH Config Option". Writing the block higher up would not be enough: it still loses to hosts pulled in by an Include above it, and a Host line moved over the options someone wrote outside any block would capture them. Write the blocks to ~/.ssh/coder/config instead and include that file from the first line of the user's config, where nothing can be parsed before it. Their config is written once to add the include, and the deployment's old block moves out of it on the next connect. The include path keeps its tilde: relative includes resolve against ~/.ssh no matter where the including file lives, and an absolute path would not survive a config synced between machines. Since placement now guarantees the options apply, the block that recomputed them and aborted the connection on a mismatch is gone. What remains of computeSshProperties reads RemoteCommand, which can only come from the user's config.
1 parent 64c44cb commit 216491f

4 files changed

Lines changed: 148 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717

1818
### Changed
1919

20+
- Write workspace SSH hosts to `~/.ssh/coder/config` and include that file
21+
from the top of your own SSH config, rather than writing the block into your
22+
config directly. SSH uses the first value it obtains for each option, so a
23+
catch-all like `Host *` used to override the connection's `ProxyCommand` and
24+
abort it with an "Unexpected SSH Config Option" error; now the extension's
25+
options win and that error is gone. Your config is only written once, to add
26+
the include, and the block for a deployment moves out of it on next connect.
2027
- Filter the Shared Workspaces view with the server-side `shared_with_user`
2128
query instead of filtering `shared:true` results on the client, so fewer
2229
workspaces are fetched and the view loads faster. Deployments too old to

src/remote/remote.ts

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ import type { SecretsManager } from "../core/secretsManager";
7777
import type { Logger } from "../logging/logger";
7878
import type { LoginCoordinator } from "../login/loginCoordinator";
7979

80+
/**
81+
* Our own config, included from the user's. Keep the tilde: relative includes
82+
* always resolve against ~/.ssh, and an absolute path would not survive a
83+
* config synced between machines.
84+
*/
85+
const CODER_SSH_CONFIG_PATH = "~/.ssh/coder/config";
86+
8087
export interface RemoteDetails extends vscode.Disposable {
8188
safeHostname: string;
8289
url: string;
@@ -953,15 +960,20 @@ export class Remote {
953960
}
954961
}
955962

956-
const sshConfigFile = this.getSshConfigPath();
957-
958-
const sshConfig = new SshConfig(sshConfigFile, this.logger);
963+
// Our blocks live in our own file; the user's only gains the include.
964+
const sshConfig = new SshConfig(this.getSshConfigPath(), this.logger);
959965
await sshConfig.load();
966+
const coderConfig = new SshConfig(
967+
expandPath(CODER_SSH_CONFIG_PATH),
968+
this.logger,
969+
);
970+
await coderConfig.load();
960971

961972
// Merge SSH config from three sources (highest to lowest priority):
962973
// 1. User's VS Code coder.sshConfig setting
963974
// 2. coder config-ssh --ssh-option flags from the CLI block
964975
// 3. Deployment SSH config from the coderd API
976+
// The CLI writes its block to the user's config, so read it from there.
965977
const configSshOptions = parseCoderSshOptions(sshConfig.getRaw());
966978
const userConfigSsh = vscode.workspace
967979
.getConfiguration("coder")
@@ -1001,42 +1013,15 @@ export class Remote {
10011013
sshValues.SetEnv = "CODER_SSH_SESSION_TYPE=vscode";
10021014
}
10031015

1004-
await sshConfig.update(safeHostname, sshValues, sshConfigOverrides);
1016+
// Write our file before including it, so the include never dangles.
1017+
await coderConfig.update(safeHostname, sshValues, sshConfigOverrides);
1018+
await sshConfig.updateInclude(CODER_SSH_CONFIG_PATH, safeHostname);
10051019

1006-
// A user can provide a "Host *" entry in their SSH config to add options
1007-
// to all hosts. We need to ensure that the options we set are not
1008-
// overridden by the user's config.
1009-
const computedProperties = computeSshProperties(
1020+
// Mirror SSH's parse order; RemoteCommand can come from the user's config.
1021+
return computeSshProperties(
10101022
hostName,
1011-
sshConfig.getRaw(),
1023+
`${coderConfig.getRaw()}\n${sshConfig.getRaw()}`,
10121024
);
1013-
const keysToMatch: Array<keyof SshValues> = [
1014-
"ProxyCommand",
1015-
"UserKnownHostsFile",
1016-
"StrictHostKeyChecking",
1017-
];
1018-
for (const key of keysToMatch) {
1019-
if (computedProperties[key] === sshValues[key]) {
1020-
continue;
1021-
}
1022-
1023-
const result = await vscodeProposed.window.showErrorMessage(
1024-
"Unexpected SSH Config Option",
1025-
{
1026-
useCustom: true,
1027-
modal: true,
1028-
detail: `Your SSH config is overriding the "${key}" property to "${computedProperties[key]}" when it expected "${sshValues[key]}" for the "${hostName}" host. Please fix this and try again!`,
1029-
},
1030-
"Reload Window",
1031-
);
1032-
if (result === "Reload Window") {
1033-
await this.reloadWindow();
1034-
}
1035-
await this.closeRemote();
1036-
throw new Error("SSH config mismatch, closing remote");
1037-
}
1038-
1039-
return computedProperties;
10401025
}
10411026

10421027
private watchSettings(

src/remote/sshConfig.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ const defaultFileSystem: FileSystem = {
5353
// Matches an SSH config key at the start of a line (e.g. "ConnectTimeout", "LogLevel").
5454
const sshKeyRegex = /^[a-zA-Z0-9-]+/;
5555

56+
const START_BLOCK_PREFIX = "# --- START CODER VSCODE";
57+
const END_BLOCK_PREFIX = "# --- END CODER VSCODE";
58+
59+
const INCLUDE_START = `${START_BLOCK_PREFIX} INCLUDE ---`;
60+
const INCLUDE_END = `${END_BLOCK_PREFIX} INCLUDE ---`;
61+
62+
/** Matches our include block wherever it currently sits. */
63+
const INCLUDE_BLOCK_REGEX = new RegExp(
64+
`^${INCLUDE_START}$.*?^${INCLUDE_END}$`,
65+
"ms",
66+
);
67+
5668
// Matches the Coder CLI's START-CODER / END-CODER block, flexible on dash count.
5769
const coderBlockRegex = /^# -+START-CODER-+$(.*?)^# -+END-CODER-+$/ms;
5870

@@ -184,10 +196,10 @@ export class SshConfig {
184196
private raw: string | undefined;
185197

186198
private startBlockComment(safeHostname: string): string {
187-
return `# --- START CODER VSCODE ${safeHostname} ---`;
199+
return `${START_BLOCK_PREFIX} ${safeHostname} ---`;
188200
}
189201
private endBlockComment(safeHostname: string): string {
190-
return `# --- END CODER VSCODE ${safeHostname} ---`;
202+
return `${END_BLOCK_PREFIX} ${safeHostname} ---`;
191203
}
192204

193205
constructor(
@@ -233,6 +245,41 @@ export class SshConfig {
233245
await this.save();
234246
}
235247

248+
/**
249+
* Include `includePath` from the first line, so the options it holds win:
250+
* SSH uses the first value it obtains for each one. Also drops the
251+
* deployment's own block, which the included file supersedes.
252+
*/
253+
async updateInclude(includePath: string, safeHostname: string) {
254+
const original = this.getRaw();
255+
const block = this.getBlock(safeHostname);
256+
if (block) {
257+
this.logger.debug("Removing superseded SSH config block", safeHostname);
258+
this.removeBlock(block);
259+
}
260+
const include = [
261+
INCLUDE_START,
262+
"# Your Coder workspaces, managed by the Coder VS Code extension. Keep first:",
263+
"# SSH uses the first value found, so anything above this block overrides them.",
264+
`Include ${includePath}`,
265+
INCLUDE_END,
266+
].join("\n");
267+
const rest = this.getRaw().replace(INCLUDE_BLOCK_REGEX, "").trim();
268+
this.raw = rest ? `${include}\n\n${rest}` : include;
269+
if (this.getRaw() !== original) {
270+
this.logger.debug("Including SSH config", includePath);
271+
await this.save();
272+
}
273+
}
274+
275+
private removeBlock(block: Block) {
276+
const raw = this.getRaw();
277+
const start = raw.indexOf(block.raw);
278+
const before = raw.slice(0, start).trimEnd();
279+
const after = raw.slice(start + block.raw.length).trimStart();
280+
this.raw = [before, after].filter(Boolean).join("\n\n");
281+
}
282+
236283
/**
237284
* Get the block for the deployment with the provided hostname.
238285
*/
@@ -262,14 +309,6 @@ export class SshConfig {
262309
return;
263310
}
264311

265-
if (startBlockIndex === -1) {
266-
throw new SshConfigBadFormat("Start block not found");
267-
}
268-
269-
if (startBlockIndex === -1) {
270-
throw new SshConfigBadFormat("End block not found");
271-
}
272-
273312
if (endBlockIndex < startBlockIndex) {
274313
throw new SshConfigBadFormat(
275314
"Malformed config, end block is before start block",
@@ -303,8 +342,8 @@ export class SshConfig {
303342
const { Host, ...otherValues } = values;
304343
const lines = [
305344
this.startBlockComment(safeHostname),
306-
"# This section is managed by the Coder VS Code extension.",
307-
"# Changes will be overwritten on the next workspace connection.",
345+
"# Rewritten by the Coder VS Code extension on every connection.",
346+
'# To change these options, use the "coder.sshConfig" setting instead.',
308347
`Host ${Host}`,
309348
];
310349

test/unit/remote/sshConfig.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import { createMockLogger } from "../../mocks/testHelpers";
1616
const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile";
1717
const sshTempFilePrefix =
1818
"/Path/To/UserHomeDir/.sshConfigDir/.sshConfigFile.vscode-coder-tmp-";
19-
const managedHeader = `# This section is managed by the Coder VS Code extension.
20-
# Changes will be overwritten on the next workspace connection.`;
19+
const managedHeader = `# Rewritten by the Coder VS Code extension on every connection.
20+
# To change these options, use the "coder.sshConfig" setting instead.`;
2121

2222
const mockFileSystem = {
2323
mkdir: vi.fn(),
@@ -858,3 +858,70 @@ Host work-server
858858
});
859859
});
860860
});
861+
862+
describe("updateInclude", () => {
863+
const include = `# --- START CODER VSCODE INCLUDE ---
864+
# Your Coder workspaces, managed by the Coder VS Code extension. Keep first:
865+
# SSH uses the first value found, so anything above this block overrides them.
866+
Include ~/.ssh/coder/config
867+
# --- END CODER VSCODE INCLUDE ---`;
868+
869+
const managedBlock = `# --- START CODER VSCODE dev.coder.com ---
870+
Host coder-vscode.dev.coder.com--*
871+
ProxyCommand some-command-here
872+
# --- END CODER VSCODE dev.coder.com ---`;
873+
874+
/** Include our config in `existing`, returning what was written, if anything. */
875+
async function updateInclude(existing: string): Promise<string | undefined> {
876+
mockFileSystem.readFile.mockResolvedValueOnce(existing);
877+
mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o644 });
878+
const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem);
879+
await sshConfig.load();
880+
await sshConfig.updateInclude("~/.ssh/coder/config", "dev.coder.com");
881+
return mockFileSystem.writeFile.mock.calls.at(-1)?.[1] as
882+
string | undefined;
883+
}
884+
885+
it("goes above everything the user wrote", async () => {
886+
const config =
887+
"AddKeysToAgent yes\n\nInclude ~/.ssh/work\n\nHost *\n ConnectTimeout 5";
888+
889+
await expect(updateInclude(config)).resolves.toBe(
890+
`${include}\n\n${config}`,
891+
);
892+
});
893+
894+
it("creates the include in an empty config", async () => {
895+
await expect(updateInclude("")).resolves.toBe(include);
896+
});
897+
898+
it("leaves the file alone when the include is already first", async () => {
899+
await expect(
900+
updateInclude(`${include}\n\nHost *`),
901+
).resolves.toBeUndefined();
902+
});
903+
904+
it("moves an include that is no longer first", async () => {
905+
const config = "Host *\n ConnectTimeout 5";
906+
907+
await expect(updateInclude(`${config}\n\n${include}`)).resolves.toBe(
908+
`${include}\n\n${config}`,
909+
);
910+
});
911+
912+
it("drops the block the included file supersedes", async () => {
913+
const config = "Host *\n ConnectTimeout 5";
914+
915+
await expect(updateInclude(`${config}\n\n${managedBlock}`)).resolves.toBe(
916+
`${include}\n\n${config}`,
917+
);
918+
});
919+
920+
it("keeps blocks belonging to other deployments", async () => {
921+
const other = managedBlock.replaceAll("dev.coder.com", "dev2.coder.com");
922+
923+
await expect(updateInclude(`${other}\n\n${managedBlock}`)).resolves.toBe(
924+
`${include}\n\n${other}`,
925+
);
926+
});
927+
});

0 commit comments

Comments
 (0)