feat: apply coder config-ssh --ssh-option to VS Code connections by EhabY · Pull Request #833 · coder/vscode-coder · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
30 changes: 17 additions & 13 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string[]>("sshConfig", []);
const userConfig = parseSshConfig(userConfigSsh);
const sshConfigOverrides = mergeSshConfigValues(
deploymentSSHConfig,
userConfig,
);

let sshConfigFile = vscode.workspace
.getConfiguration()
.get<string>("remote.SSH.configFile");
Expand All @@ -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<string[]>("sshConfig", []);
const userConfig = parseSshConfig(userConfigSsh);
const sshConfigOverrides = mergeSshConfigValues(
mergeSshConfigValues(deploymentSSHConfig, configSshOptions),
userConfig,
);

const hostPrefix = safeHostname
? `${AuthorityPrefix}.${safeHostname}--`
: `${AuthorityPrefix}--`;
Expand Down Expand Up @@ -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(),
);
Expand Down
35 changes: 32 additions & 3 deletions src/remote/sshConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-]+/;
Comment thread
EhabY marked this conversation as resolved.

// 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.
Expand All @@ -44,8 +50,7 @@ const defaultFileSystem: FileSystem = {
export function parseSshConfig(lines: string[]): Record<string, string> {
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
}
Expand Down Expand Up @@ -74,6 +79,25 @@ export function parseSshConfig(lines: string[]): Record<string, string> {
);
}

/**
* Extract `# :ssh-option=` values from the Coder CLI's config block.
* Returns `{}` if no CLI block is found.
*/
export function parseCoderSshOptions(raw: string): Record<string, string> {
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.
Expand Down Expand Up @@ -255,7 +279,12 @@ export class SSHConfig {
overrides?: Record<string, string>,
) {
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 ?? {});
Expand Down
23 changes: 15 additions & 8 deletions src/remote/sshSupport.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(".");
Expand All @@ -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<string, string> {
Expand Down
106 changes: 106 additions & 0 deletions test/unit/remote/sshConfig.test.ts
Loading
Loading