Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

## Unreleased

### Added

- Automatic TLS client certificate refresh via new `coder.tlsCertRefreshCommand` setting. Detects
certificate errors (expired, revoked, etc.) and automatically refreshes and retries.

### Fixed

- Fixed `SetEnv` SSH config parsing and accumulation with user-defined values.
- Improved WebSocket error handling for more consistent behavior across connection failures.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: other PR?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in #732 (comment), this was always in issue but I only noticed it now when catching cert errors


## [v1.11.6](https://github.com/coder/vscode-coder/releases/tag/v1.11.6) 2025-12-15

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@
"type": "string",
"default": ""
},
"coder.tlsCertRefreshCommand": {
"markdownDescription": "Command to run when TLS client certificate errors occur (e.g., expired, revoked, or rejected certificates). If configured, the extension will automatically execute this command and retry failed requests. `http.proxySupport` must be set to `on` or `off`, otherwise VS Code will override the proxy agent set by the plugin.",
"type": "string",
"default": ""
},
"coder.proxyLogDirectory": {
"markdownDescription": "If set, the Coder CLI will output extra SSH information into this directory, which can be helpful for debugging connectivity issues.",
"type": "string",
Expand Down
30 changes: 30 additions & 0 deletions src/api/certificateRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as vscode from "vscode";

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

/**
* Returns the configured certificate refresh command, or undefined if not set.
*/
export function getRefreshCommand(): string | undefined {
return (
vscode.workspace
.getConfiguration()
.get<string>("coder.tlsCertRefreshCommand")
?.trim() || undefined
);
}

/**
* Executes the certificate refresh command.
* Returns true if successful, false otherwise.
*/
export async function refreshCertificates(
command: string,
logger: Logger,
): Promise<boolean> {
const result = await execCommand(command, logger, {
title: "Certificate refresh",
});
return result.success;
}
82 changes: 67 additions & 15 deletions src/api/coderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import * as vscode from "vscode";
import { type ClientOptions } from "ws";

import { watchConfigurationChanges } from "../configWatcher";
import { CertificateError } from "../error/certificateError";
import { ClientCertificateError } from "../error/clientCertificateError";
import { toError } from "../error/errorUtils";
import { ServerCertificateError } from "../error/serverCertificateError";
import { getHeaderCommand, getHeaders } from "../headers";
import { EventStreamLogger } from "../logging/eventStreamLogger";
import {
Expand Down Expand Up @@ -49,6 +50,7 @@ import {
} from "../websocket/reconnectingWebSocket";
import { SseConnection } from "../websocket/sseConnection";

import { getRefreshCommand, refreshCertificates } from "./certificateRefresh";
import { createHttpAgent } from "./utils";

const coderSessionTokenHeader = "Coder-Session-Token";
Expand Down Expand Up @@ -309,7 +311,9 @@ export class CoderApi extends Api implements vscode.Disposable {
});

this.attachStreamLogger(ws);
return ws;

// Wait for connection to open before returning
return await this.waitForOpen(ws);
}

private attachStreamLogger<TData>(
Expand Down Expand Up @@ -349,9 +353,8 @@ export class CoderApi extends Api implements vscode.Disposable {
): Promise<UnidirectionalStream<ServerSentEvent>> {
const { fallbackApiRoute, ...socketConfigs } = configs;
try {
const ws =
await this.createOneWayWebSocket<ServerSentEvent>(socketConfigs);
return await this.waitForOpen(ws);
// createOneWayWebSocket already waits for open
return await this.createOneWayWebSocket<ServerSentEvent>(socketConfigs);
} catch (error) {
if (this.is404Error(error)) {
this.output.warn(
Expand Down Expand Up @@ -396,10 +399,11 @@ export class CoderApi extends Api implements vscode.Disposable {

/**
* Wait for a connection to open. Rejects on error.
* Preserves the specific connection type (e.g., OneWayWebSocket, SseConnection).
*/
private waitForOpen<TData>(
connection: UnidirectionalStream<TData>,
): Promise<UnidirectionalStream<TData>> {
private waitForOpen<T extends UnidirectionalStream<unknown>>(
connection: T,
): Promise<T> {
return new Promise((resolve, reject) => {
const cleanup = () => {
connection.removeEventListener("open", handleOpen);
Expand All @@ -414,7 +418,10 @@ export class CoderApi extends Api implements vscode.Disposable {
const handleError = (event: ErrorEvent) => {
cleanup();
connection.close();
const error = toError(event.error, "WebSocket connection error");
const error = toError(
event.error,
event.message || "WebSocket connection error",
);
reject(error);
};

Expand All @@ -440,7 +447,15 @@ export class CoderApi extends Api implements vscode.Disposable {
const reconnectingSocket = await ReconnectingWebSocket.create<TData>(
socketFactory,
this.output,
undefined,
{
onCertificateRefreshNeeded: async () => {
const refreshCommand = getRefreshCommand();
if (!refreshCommand) {
return false;
}
return refreshCertificates(refreshCommand, this.output);
},
},
() => this.reconnectingSockets.delete(reconnectingSocket),
);

Expand Down Expand Up @@ -479,16 +494,53 @@ function setupInterceptors(client: CoderApi, output: Logger): void {
return config;
});

// Wrap certificate errors.
// Wrap certificate errors and handle client certificate errors with refresh.
client.getAxiosInstance().interceptors.response.use(
(r) => r,
async (err) => {
async (err: unknown) => {
const refreshCommand = getRefreshCommand();
const certError = ClientCertificateError.fromError(err);
if (certError) {
if (certError.isRefreshable && refreshCommand) {
const config = (
err as {
config?: RequestConfigWithMeta & {
_certRetried?: boolean;
};
}
).config;

if (config && !config._certRetried) {
config._certRetried = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm following the logic here -- we send _certRetried in the outgoing request?


output.info(
`Client certificate error (alert ${certError.alertCode}), attempting refresh...`,
);
const success = await refreshCertificates(refreshCommand, output);
if (success) {
// Create new agent with refreshed certificates.
const agent = await createHttpAgent(
vscode.workspace.getConfiguration(),
);
config.httpsAgent = agent;
config.httpAgent = agent;

// Retry the request.
output.info("Retrying request with refreshed certificates...");
return client.getAxiosInstance().request(config);
}
}
}

throw certError;
}

// Handle other certificate errors.
const baseUrl = client.getAxiosInstance().defaults.baseURL;
if (baseUrl) {
throw await CertificateError.maybeWrap(err, baseUrl, output);
} else {
throw err;
throw await ServerCertificateError.maybeWrap(err, baseUrl, output);
}
throw err;
},
);
}
Expand Down
74 changes: 74 additions & 0 deletions src/command/exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as cp from "node:child_process";
import * as util from "node:util";

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

interface ExecException {
code?: number;
stderr?: string;
stdout?: string;
}

function isExecException(err: unknown): err is ExecException {
return (err as ExecException).code !== undefined;
}

export interface ExecCommandOptions {
env?: NodeJS.ProcessEnv;
/** Title for logging (e.g., "Header command", "Certificate refresh"). */
title?: string;
}

export type ExecCommandResult =
| { success: true; stdout: string; stderr: string }
| { success: false; stdout?: string; stderr?: string; exitCode?: number };

/**
* Execute a shell command and return result with success/failure.
* Handles errors gracefully and logs appropriately.
*/
export async function execCommand(
command: string,
logger: Logger,
options?: ExecCommandOptions,
): Promise<ExecCommandResult> {
const title = options?.title ?? "Command";
logger.debug(`Executing ${title}: ${command}`);

try {
const result = await util.promisify(cp.exec)(command, {
env: options?.env,
});
logger.debug(`${title} completed successfully`);
if (result.stdout) {
logger.debug(`${title} stdout:`, result.stdout);
}
if (result.stderr) {
logger.debug(`${title} stderr:`, result.stderr);
}
return {
success: true,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
if (isExecException(error)) {
logger.warn(`${title} failed with exit code ${error.code}`);
if (error.stdout) {
logger.warn(`${title} stdout:`, error.stdout);
}
if (error.stderr) {
logger.warn(`${title} stderr:`, error.stderr);
}
return {
success: false,
stdout: error.stdout,
stderr: error.stderr,
exitCode: error.code,
};
}

logger.warn(`${title} failed:`, error);
return { success: false };
}
}
Loading