diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e08bf3..e9f3e218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Proxy log directory now defaults to the extension's global storage when `coder.proxyLogDirectory` is not set, so SSH connection logs are always captured without manual configuration. Also respects the `CODER_SSH_LOG_DIR` environment variable as a fallback. +- SSH options from `coder config-ssh --ssh-option` are now applied to VS Code connections, + with priority order: VS Code setting > `coder config-ssh` options > deployment config. ## [v1.14.0-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.0-pre) 2026-03-06 diff --git a/src/remote/remote.ts b/src/remote/remote.ts index a3f59aef..ac7c99ca 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -55,10 +55,11 @@ import { SSHConfig, type SSHValues, mergeSshConfigValues, + parseCoderSshOptions, parseSshConfig, } from "./sshConfig"; import { SshProcessMonitor } from "./sshProcess"; -import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; +import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport"; import { WorkspaceStateMachine } from "./workspaceStateMachine"; export interface RemoteDetails extends vscode.Disposable { @@ -816,17 +817,6 @@ export class Remote { } } - // deploymentConfig is now set from the remote coderd deployment. - // Now override with the user's config. - const userConfigSsh = vscode.workspace - .getConfiguration("coder") - .get("sshConfig", []); - const userConfig = parseSshConfig(userConfigSsh); - const sshConfigOverrides = mergeSshConfigValues( - deploymentSSHConfig, - userConfig, - ); - let sshConfigFile = vscode.workspace .getConfiguration() .get("remote.SSH.configFile"); @@ -842,6 +832,20 @@ export class Remote { const sshConfig = new SSHConfig(sshConfigFile); await sshConfig.load(); + // Merge SSH config from three sources (highest to lowest priority): + // 1. User's VS Code coder.sshConfig setting + // 2. coder config-ssh --ssh-option flags from the CLI block + // 3. Deployment SSH config from the coderd API + const configSshOptions = parseCoderSshOptions(sshConfig.getRaw()); + const userConfigSsh = vscode.workspace + .getConfiguration("coder") + .get("sshConfig", []); + const userConfig = parseSshConfig(userConfigSsh); + const sshConfigOverrides = mergeSshConfigValues( + mergeSshConfigValues(deploymentSSHConfig, configSshOptions), + userConfig, + ); + const hostPrefix = safeHostname ? `${AuthorityPrefix}.${safeHostname}--` : `${AuthorityPrefix}--`; @@ -874,7 +878,7 @@ export class Remote { // A user can provide a "Host *" entry in their SSH config to add options // to all hosts. We need to ensure that the options we set are not // overridden by the user's config. - const computedProperties = computeSSHProperties( + const computedProperties = computeSshProperties( hostName, sshConfig.getRaw(), ); diff --git a/src/remote/sshConfig.ts b/src/remote/sshConfig.ts index 5994feeb..2ac221ff 100644 --- a/src/remote/sshConfig.ts +++ b/src/remote/sshConfig.ts @@ -36,6 +36,12 @@ const defaultFileSystem: FileSystem = { writeFile, }; +// Matches an SSH config key at the start of a line (e.g. "ConnectTimeout", "LogLevel"). +const sshKeyRegex = /^[a-zA-Z0-9-]+/; + +// Matches the Coder CLI's START-CODER / END-CODER block, flexible on dash count. +const coderBlockRegex = /^# -+START-CODER-+$(.*?)^# -+END-CODER-+$/ms; + /** * Parse an array of SSH config lines into a Record. * Handles both "Key value" and "Key=value" formats. @@ -44,8 +50,7 @@ const defaultFileSystem: FileSystem = { export function parseSshConfig(lines: string[]): Record { return lines.reduce( (acc, line) => { - // Match key pattern (same as VS Code settings: ^[a-zA-Z0-9-]+) - const keyMatch = /^[a-zA-Z0-9-]+/.exec(line); + const keyMatch = sshKeyRegex.exec(line); if (!keyMatch) { return acc; // Malformed line } @@ -74,6 +79,25 @@ export function parseSshConfig(lines: string[]): Record { ); } +/** + * Extract `# :ssh-option=` values from the Coder CLI's config block. + * Returns `{}` if no CLI block is found. + */ +export function parseCoderSshOptions(raw: string): Record { + const blockMatch = coderBlockRegex.exec(raw); + const block = blockMatch?.[1]; + if (!block) { + return {}; + } + const prefix = "# :ssh-option="; + const sshOptionLines = block + .split(/\r?\n/) + .filter((line) => line.startsWith(prefix)) + .map((line) => line.slice(prefix.length)); + + return parseSshConfig(sshOptionLines); +} + // mergeSSHConfigValues will take a given ssh config and merge it with the overrides // provided. The merge handles key case insensitivity, so casing in the "key" does // not matter. @@ -255,7 +279,12 @@ export class SSHConfig { overrides?: Record, ) { const { Host, ...otherValues } = values; - const lines = [this.startBlockComment(safeHostname), `Host ${Host}`]; + const lines = [ + this.startBlockComment(safeHostname), + "# This section is managed by the Coder VS Code extension.", + "# Changes will be overwritten on the next workspace connection.", + `Host ${Host}`, + ]; // configValues is the merged values of the defaults and the overrides. const configValues = mergeSshConfigValues(otherValues, overrides ?? {}); diff --git a/src/remote/sshSupport.ts b/src/remote/sshSupport.ts index 5d189ce8..152891f3 100644 --- a/src/remote/sshSupport.ts +++ b/src/remote/sshSupport.ts @@ -1,5 +1,10 @@ import * as childProcess from "child_process"; +// Matches the OpenSSH version number from `ssh -V` output. +// [^,]* prevents greedy matching across comma-separated components +const openSSHVersionRegex = /OpenSSH[^,]*_([\d.]+)/; + +/** Check if the local SSH installation supports the `SetEnv` directive. */ export function sshSupportsSetEnv(): boolean { try { // Run `ssh -V` to get the version string. @@ -11,12 +16,12 @@ export function sshSupportsSetEnv(): boolean { } } -// sshVersionSupportsSetEnv ensures that the version string from the SSH -// command line supports the `SetEnv` directive. -// -// It was introduced in SSH 7.8 and not all versions support it. +/** + * Check if an SSH version string supports the `SetEnv` directive. + * Requires OpenSSH 7.8 or later. + */ export function sshVersionSupportsSetEnv(sshVersionString: string): boolean { - const match = /OpenSSH.*_([\d.]+)[^,]*/.exec(sshVersionString); + const match = openSSHVersionRegex.exec(sshVersionString); if (match?.[1]) { const installedVersion = match[1]; const parts = installedVersion.split("."); @@ -37,9 +42,11 @@ export function sshVersionSupportsSetEnv(sshVersionString: string): boolean { return false; } -// computeSSHProperties accepts an SSH config and a host name and returns -// the properties that should be set for that host. -export function computeSSHProperties( +/** + * Compute the effective SSH properties for a given host by evaluating + * all matching Host blocks in the provided SSH config. + */ +export function computeSshProperties( host: string, config: string, ): Record { diff --git a/test/unit/remote/sshConfig.test.ts b/test/unit/remote/sshConfig.test.ts index eab774dc..a68c0306 100644 --- a/test/unit/remote/sshConfig.test.ts +++ b/test/unit/remote/sshConfig.test.ts @@ -2,6 +2,7 @@ import { it, afterEach, vi, expect, describe } from "vitest"; import { SSHConfig, + parseCoderSshOptions, parseSshConfig, mergeSshConfigValues, } from "@/remote/sshConfig"; @@ -12,6 +13,8 @@ import { const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile"; const sshTempFilePrefix = "/Path/To/UserHomeDir/.sshConfigDir/.sshConfigFile.vscode-coder-tmp-"; +const managedHeader = `# This section is managed by the Coder VS Code extension. +# Changes will be overwritten on the next workspace connection.`; const mockFileSystem = { mkdir: vi.fn(), @@ -41,6 +44,7 @@ it("creates a new file and adds config with empty label", async () => { }); const expectedOutput = `# --- START CODER VSCODE --- +${managedHeader} Host coder-vscode--* ConnectTimeout 0 LogLevel ERROR @@ -83,6 +87,7 @@ it("creates a new file and adds the config", async () => { }); const expectedOutput = `# --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR @@ -134,6 +139,7 @@ it("adds a new coder config in an existent SSH configuration", async () => { const expectedOutput = `${existentSSHConfig} # --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR @@ -204,6 +210,7 @@ Host * const expectedOutput = `${keepSSHConfig} # --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev-updated.coder.com--* ConnectTimeout 1 LogLevel ERROR @@ -261,6 +268,7 @@ Host coder-vscode--* const expectedOutput = `${existentSSHConfig} # --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR @@ -304,6 +312,7 @@ it("it does not remove a user-added block that only matches the host of an old c ForwardAgent=yes # --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR @@ -574,6 +583,7 @@ Host donotdelete User please # --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR @@ -638,6 +648,7 @@ it("override values", async () => { ); const expectedOutput = `# --- START CODER VSCODE dev.coder.com --- +${managedHeader} Host coder-vscode.dev.coder.com--* Buzz baz ConnectTimeout 500 @@ -854,3 +865,98 @@ describe("mergeSshConfigValues", () => { expect(mergeSshConfigValues(config, overrides)).toEqual(expected); }); }); + +describe("parseCoderSshOptions", () => { + const coderBlock = (...lines: string[]) => + `# ------------START-CODER-----------\n${lines.join("\n")}\n# ------------END-CODER------------`; + + interface SshOptionTestCase { + name: string; + raw: string; + expected: Record; + } + it.each([ + { + name: "empty string", + raw: "", + expected: {}, + }, + { + name: "no CLI block", + raw: "Host myhost\n HostName example.com", + expected: {}, + }, + { + name: "single option", + raw: coderBlock("# :ssh-option=ForwardX11=yes"), + expected: { ForwardX11: "yes" }, + }, + { + name: "multiple options", + raw: coderBlock( + "# :ssh-option=ForwardX11=yes", + "# :ssh-option=ForwardX11Trusted=yes", + ), + expected: { ForwardX11: "yes", ForwardX11Trusted: "yes" }, + }, + { + name: "ignores non-ssh-option keys", + raw: coderBlock( + "# :wait=yes", + "# :disable-autostart=true", + "# :ssh-option=ForwardX11=yes", + ), + expected: { ForwardX11: "yes" }, + }, + { + name: "accumulates SetEnv across lines", + raw: coderBlock( + "# :ssh-option=SetEnv=FOO=1", + "# :ssh-option=SetEnv=BAR=2", + ), + expected: { SetEnv: "FOO=1 BAR=2" }, + }, + { + name: "tolerates different dash counts in markers", + raw: `# ---START-CODER---\n# :ssh-option=ForwardX11=yes\n# ---END-CODER---`, + expected: { ForwardX11: "yes" }, + }, + ])("$name", ({ raw, expected }) => { + expect(parseCoderSshOptions(raw)).toEqual(expected); + }); + + it("extracts only ssh-options from a full config", () => { + const raw = `Host personal-server + HostName 10.0.0.1 + User admin + +# ------------START-CODER----------- +# This file is managed by coder. DO NOT EDIT. +# +# You should not hand-edit this file, changes may be overwritten. +# For more information, see https://coder.com/docs +# +# :wait=yes +# :disable-autostart=true +# :ssh-option=ForwardX11=yes +# :ssh-option=ForwardX11Trusted=yes + +Host coder.mydeployment--* + ConnectTimeout 0 + ForwardX11 yes + ForwardX11Trusted yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + ProxyCommand /usr/bin/coder ssh --stdio --ssh-host-prefix coder.mydeployment-- %h +# ------------END-CODER------------ + +Host work-server + HostName 10.0.0.2 + User work`; + expect(parseCoderSshOptions(raw)).toEqual({ + ForwardX11: "yes", + ForwardX11Trusted: "yes", + }); + }); +}); diff --git a/test/unit/remote/sshSupport.test.ts b/test/unit/remote/sshSupport.test.ts index bb152bd8..8da0eb6f 100644 --- a/test/unit/remote/sshSupport.test.ts +++ b/test/unit/remote/sshSupport.test.ts @@ -1,7 +1,7 @@ import { it, expect } from "vitest"; import { - computeSSHProperties, + computeSshProperties, sshSupportsSetEnv, sshVersionSupportsSetEnv, } from "@/remote/sshSupport"; @@ -10,6 +10,8 @@ const supports = { "OpenSSH_8.9p1 Ubuntu-3ubuntu0.1, OpenSSL 3.0.2 15 Mar 2022": true, "OpenSSH_for_Windows_8.1p1, LibreSSL 3.0.2": true, "OpenSSH_9.0p1, LibreSSL 3.3.6": true, + // Version extracted from OpenSSH, not from LibreSSL after the comma + "OpenSSH_7.4p1, LibreSSL_8.1.0": false, "OpenSSH_7.6p1 Ubuntu-4ubuntu0.7, OpenSSL 1.0.2n 7 Dec 2017": false, "OpenSSH_7.4p1, OpenSSL 1.0.2k-fips 26 Jan 2017": false, }; @@ -25,7 +27,7 @@ it("current shell supports ssh", () => { }); it("computes the config for a host", () => { - const properties = computeSSHProperties( + const properties = computeSshProperties( "coder-vscode--testing", `Host * StrictHostKeyChecking yes @@ -47,7 +49,7 @@ Host coder-vscode--* }); it("handles ? wildcards", () => { - const properties = computeSSHProperties( + const properties = computeSshProperties( "coder-vscode--testing", `Host * StrictHostKeyChecking yes @@ -75,7 +77,7 @@ Host coder-v?code--* }); it("properly escapes meaningful regex characters", () => { - const properties = computeSSHProperties( + const properties = computeSshProperties( "coder-vscode.dev.coder.com--matalfi--dogfood", `Host * StrictHostKeyChecking yes