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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Added

- Automatically set `reconnectionGraceTime`, `serverShutdownTimeout`, and `maxReconnectionAttempts`
on first connection to prevent disconnects during overnight workspace sleep.
- New **Coder: Apply Recommended SSH Settings** command to overwrite all recommended SSH settings at once.
- 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.
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@
"title": "Refresh Tasks",
"category": "Coder",
"icon": "$(refresh)"
},
{
"command": "coder.applyRecommendedSettings",
"title": "Apply Recommended SSH Settings",
"category": "Coder"
}
],
"menus": {
Expand Down Expand Up @@ -386,6 +391,9 @@
{
"command": "coder.tasks.refresh",
"when": "false"
},
{
"command": "coder.applyRecommendedSettings"
}
],
"view/title": [
Expand Down
53 changes: 53 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { type Logger } from "./logging/logger";
import { type LoginCoordinator } from "./login/loginCoordinator";
import { withProgress } from "./progress";
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
import {
RECOMMENDED_SSH_SETTINGS,
applySettingOverrides,
} from "./remote/userSettings";
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
import { vscodeProposed } from "./vscodeProposed";
import {
Expand Down Expand Up @@ -310,6 +314,55 @@ export class Commands {
}
}

/**
* Apply recommended SSH settings for reliable Coder workspace connections.
*/
public async applyRecommendedSettings(): Promise<void> {
const entries = Object.entries(RECOMMENDED_SSH_SETTINGS);
const summary = entries.map(([, s]) => s.label).join("\n");
const confirm = await vscodeProposed.window.showWarningMessage(
"Apply Recommended SSH Settings",
{
useCustom: true,
modal: true,
detail: summary,
},
"Apply",
);
if (confirm !== "Apply") {
return;
}

const overrides = entries.map(([key, setting]) => ({
key,
value: setting.value,
}));
const ok = await applySettingOverrides(
this.pathResolver.getUserSettingsPath(),
overrides,
this.logger,
);
if (!ok) {
const action = await vscode.window.showErrorMessage(
"Failed to write SSH settings. Check the Coder output for details.",
"Show Output",
);
if (action === "Show Output") {
this.logger.show();
}
} else if (this.remoteWorkspaceClient) {
const action = await vscode.window.showInformationMessage(
"Applied recommended SSH settings. Reload the window for changes to take effect.",
"Reload Window",
);
if (action === "Reload Window") {
await vscode.commands.executeCommand("workbench.action.reloadWindow");
}
} else {
vscode.window.showInformationMessage("Applied recommended SSH settings.");
}
}

/**
* Create a new workspace for the currently logged-in deployment.
*
Expand Down
4 changes: 4 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
"coder.manageCredentials",
commands.manageCredentials.bind(commands),
),
vscode.commands.registerCommand(
"coder.applyRecommendedSettings",
commands.applyRecommendedSettings.bind(commands),
),
);

const remote = new Remote(serviceContainer, commands, ctx);
Expand Down
1 change: 1 addition & 0 deletions src/logging/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface Logger {
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
show(): void;
}
87 changes: 12 additions & 75 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
type Workspace,
type WorkspaceAgent,
} from "coder/site/src/api/typesGenerated";
import * as jsonc from "jsonc-parser";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
Expand Down Expand Up @@ -60,6 +59,7 @@ import {
} from "./sshConfig";
import { SshProcessMonitor } from "./sshProcess";
import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport";
import { applySettingOverrides, buildSshOverrides } from "./userSettings";
import { WorkspaceStateMachine } from "./workspaceStateMachine";

export interface RemoteDetails extends vscode.Disposable {
Expand Down Expand Up @@ -459,83 +459,20 @@ export class Remote {
const inbox = await Inbox.create(workspace, workspaceClient, this.logger);
disposables.push(inbox);

// Do some janky setting manipulation.
this.logger.info("Modifying settings...");
const remotePlatforms = vscodeProposed.workspace
.getConfiguration()
.get<Record<string, string>>("remote.SSH.remotePlatform", {});
const connTimeout = vscodeProposed.workspace
.getConfiguration()
.get<number | undefined>("remote.SSH.connectTimeout");

// We have to directly munge the settings file with jsonc because trying to
// update properly through the extension API hangs indefinitely. Possibly
// VS Code is trying to update configuration on the remote, which cannot
// connect until we finish here leading to a deadlock. We need to update it
// locally, anyway, and it does not seem possible to force that via API.
let settingsContent = "{}";
try {
settingsContent = await fs.readFile(
const overrides = buildSshOverrides(
vscodeProposed.workspace.getConfiguration(),
parts.sshHost,
agent.operating_system,
);
if (overrides.length > 0) {
const ok = await applySettingOverrides(
this.pathResolver.getUserSettingsPath(),
"utf8",
);
} catch {
// Ignore! It's probably because the file doesn't exist.
}

// Add the remote platform for this host to bypass a step where VS Code asks
// the user for the platform.
let mungedPlatforms = false;
if (
!remotePlatforms[parts.sshHost] ||
remotePlatforms[parts.sshHost] !== agent.operating_system
) {
remotePlatforms[parts.sshHost] = agent.operating_system;
settingsContent = jsonc.applyEdits(
settingsContent,
jsonc.modify(
settingsContent,
["remote.SSH.remotePlatform"],
remotePlatforms,
{},
),
overrides,
this.logger,
);
mungedPlatforms = true;
}

// VS Code ignores the connect timeout in the SSH config and uses a default
// of 15 seconds, which can be too short in the case where we wait for
// startup scripts. For now we hardcode a longer value. Because this is
// potentially overwriting user configuration, it feels a bit sketchy. If
// microsoft/vscode-remote-release#8519 is resolved we can remove this.
const minConnTimeout = 1800;
let mungedConnTimeout = false;
if (!connTimeout || connTimeout < minConnTimeout) {
settingsContent = jsonc.applyEdits(
settingsContent,
jsonc.modify(
settingsContent,
["remote.SSH.connectTimeout"],
minConnTimeout,
{},
),
);
mungedConnTimeout = true;
}

if (mungedPlatforms || mungedConnTimeout) {
try {
await fs.writeFile(
this.pathResolver.getUserSettingsPath(),
settingsContent,
);
} catch (ex) {
// This could be because the user's settings.json is read-only. This is
// the case when using home-manager on NixOS, for example. Failure to
// write here is not necessarily catastrophic since the user will be
// asked for the platform and the default timeout might be sufficient.
mungedPlatforms = mungedConnTimeout = false;
this.logger.warn("Failed to configure settings", ex);
if (ok) {
this.logger.info("Settings modified successfully");
}
}

Expand Down
141 changes: 141 additions & 0 deletions src/remote/userSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { formatDuration, intervalToDuration } from "date-fns";
import * as jsonc from "jsonc-parser";
import * as fs from "node:fs/promises";

import type { WorkspaceConfiguration } from "vscode";

import type { Logger } from "../logging/logger";

export interface SettingOverride {
key: string;
value: unknown;
}

interface RecommendedSetting {
readonly value: number | null;
readonly label: string;
}

function recommended(
shortName: string,
value: number | null,
): RecommendedSetting {
if (value === null) {
return { value, label: `${shortName}: max allowed` };
}
const humanized = formatDuration(
intervalToDuration({ start: 0, end: value * 1000 }),
);
return { value, label: `${shortName}: ${humanized}` };
}

/**
* Applied by the "Apply Recommended SSH Settings" command.
* These are more aggressive (24h) than AUTO_SETUP_DEFAULTS (8h) because the
* user is explicitly opting in via the command palette.
*/
export const RECOMMENDED_SSH_SETTINGS = {
"remote.SSH.connectTimeout": recommended("Connect Timeout", 1800),
"remote.SSH.reconnectionGraceTime": recommended(
"Reconnection Grace Time",
86400,
),
"remote.SSH.serverShutdownTimeout": recommended(
"Server Shutdown Timeout",
86400,
),
"remote.SSH.maxReconnectionAttempts": recommended(
"Max Reconnection Attempts",
null,
),
} as const satisfies Record<string, RecommendedSetting>;

type SshSettingKey = keyof typeof RECOMMENDED_SSH_SETTINGS;

/** Defaults set during connection when the user hasn't configured a value. */
const AUTO_SETUP_DEFAULTS = {
"remote.SSH.reconnectionGraceTime": 28800, // 8h
"remote.SSH.serverShutdownTimeout": 28800, // 8h
"remote.SSH.maxReconnectionAttempts": null, // max allowed
} as const satisfies Partial<Record<SshSettingKey, number | null>>;

/**
* Build the list of VS Code setting overrides needed for a remote SSH
* connection to a Coder workspace.
*/
export function buildSshOverrides(
config: Pick<WorkspaceConfiguration, "get">,
sshHost: string,
agentOS: string,
): SettingOverride[] {
const overrides: SettingOverride[] = [];

// Set the remote platform for this host to bypass the platform prompt.
const remotePlatforms = config.get<Record<string, string>>(
"remote.SSH.remotePlatform",
{},
);
if (remotePlatforms[sshHost] !== agentOS) {
overrides.push({
key: "remote.SSH.remotePlatform",
value: { ...remotePlatforms, [sshHost]: agentOS },
});
}

// Default 15s is too short for startup scripts; enforce a minimum.
const connTimeoutKey: SshSettingKey = "remote.SSH.connectTimeout";
const { value: minConnTimeout } = RECOMMENDED_SSH_SETTINGS[connTimeoutKey];
const connTimeout = config.get<number>(connTimeoutKey);
if (minConnTimeout && (!connTimeout || connTimeout < minConnTimeout)) {
overrides.push({ key: connTimeoutKey, value: minConnTimeout });
}

// Set conservative defaults for settings the user hasn't configured.
for (const [key, value] of Object.entries(AUTO_SETUP_DEFAULTS)) {
if (config.get(key) === undefined) {
overrides.push({ key, value });
}
}

return overrides;
}

/**
* Apply setting overrides to the user's settings.json file.
*
* We munge the file directly with jsonc instead of using the VS Code API
* because the API hangs indefinitely during remote connection setup (likely
* a deadlock from trying to update config on the not-yet-connected remote).
*/
export async function applySettingOverrides(
settingsFilePath: string,
overrides: SettingOverride[],
logger: Logger,
): Promise<boolean> {
if (overrides.length === 0) {
return true;
}

let settingsContent = "{}";
try {
settingsContent = await fs.readFile(settingsFilePath, "utf8");
} catch {
// File probably doesn't exist yet.
}

for (const { key, value } of overrides) {
settingsContent = jsonc.applyEdits(
settingsContent,
jsonc.modify(settingsContent, [key], value, {}),
);
}

try {
await fs.writeFile(settingsFilePath, settingsContent);
return true;
} catch (ex) {
// Could be read-only (e.g. home-manager on NixOS). Not catastrophic.
logger.warn("Failed to configure settings", ex);
return false;
}
}
1 change: 1 addition & 0 deletions test/mocks/testHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ export function createMockLogger(): Logger {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
show: vi.fn(),
};
}

Expand Down
1 change: 1 addition & 0 deletions test/unit/error/serverCertificateError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("Certificate errors", () => {
info: throwingLog,
warn: throwingLog,
error: throwingLog,
show: () => {},
};

const disposers: Array<() => void> = [];
Expand Down
Loading
Loading