Skip to content

Commit 2f8782c

Browse files
authored
Add automatic TLS client certificate refresh on SSL errors (#732)
Detect TLS client certificate errors via SSL alert codes and automatically refresh certificates using a configurable command before retrying failed requests. This helps environments where certificates expire frequently. Certificate errors are now split into ClientCertificateError and ServerCertificateError with distinct handling. Client cert errors are classified as refreshable (expired, revoked, bad_certificate, unknown) or non-refreshable (unknown_ca, unsupported, access_denied) based on whether a certificate refresh could resolve the issue. HTTP requests and WebSocket connections both support refresh-and-retry with a once-per-request/connection limit to prevent infinite loops. Fixed #714
1 parent 8fa5f71 commit 2f8782c

19 files changed

+1299
-259
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- Automatic TLS client certificate refresh via new `coder.tlsCertRefreshCommand` setting. Detects
8+
certificate errors (expired, revoked, etc.) and automatically refreshes and retries.
9+
510
### Fixed
611

712
- Fixed `SetEnv` SSH config parsing and accumulation with user-defined values.
13+
- Improved WebSocket error handling for more consistent behavior across connection failures.
814

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

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@
9292
"type": "string",
9393
"default": ""
9494
},
95+
"coder.tlsCertRefreshCommand": {
96+
"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.",
97+
"type": "string",
98+
"default": ""
99+
},
95100
"coder.proxyLogDirectory": {
96101
"markdownDescription": "If set, the Coder CLI will output extra SSH information into this directory, which can be helpful for debugging connectivity issues.",
97102
"type": "string",

src/api/certificateRefresh.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as vscode from "vscode";
2+
3+
import { execCommand } from "../command/exec";
4+
import { type Logger } from "../logging/logger";
5+
6+
/**
7+
* Returns the configured certificate refresh command, or undefined if not set.
8+
*/
9+
export function getRefreshCommand(): string | undefined {
10+
return (
11+
vscode.workspace
12+
.getConfiguration()
13+
.get<string>("coder.tlsCertRefreshCommand")
14+
?.trim() || undefined
15+
);
16+
}
17+
18+
/**
19+
* Executes the certificate refresh command.
20+
* Returns true if successful, false otherwise.
21+
*/
22+
export async function refreshCertificates(
23+
command: string,
24+
logger: Logger,
25+
): Promise<boolean> {
26+
const result = await execCommand(command, logger, {
27+
title: "Certificate refresh",
28+
});
29+
return result.success;
30+
}

src/api/coderApi.ts

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type AxiosInstance,
44
type AxiosHeaders,
55
type AxiosResponseTransformer,
6+
isAxiosError,
67
} from "axios";
78
import { Api } from "coder/site/src/api/api";
89
import {
@@ -17,8 +18,9 @@ import * as vscode from "vscode";
1718
import { type ClientOptions } from "ws";
1819

1920
import { watchConfigurationChanges } from "../configWatcher";
20-
import { CertificateError } from "../error/certificateError";
21+
import { ClientCertificateError } from "../error/clientCertificateError";
2122
import { toError } from "../error/errorUtils";
23+
import { ServerCertificateError } from "../error/serverCertificateError";
2224
import { getHeaderCommand, getHeaders } from "../headers";
2325
import { EventStreamLogger } from "../logging/eventStreamLogger";
2426
import {
@@ -49,6 +51,7 @@ import {
4951
} from "../websocket/reconnectingWebSocket";
5052
import { SseConnection } from "../websocket/sseConnection";
5153

54+
import { getRefreshCommand, refreshCertificates } from "./certificateRefresh";
5255
import { createHttpAgent } from "./utils";
5356

5457
const coderSessionTokenHeader = "Coder-Session-Token";
@@ -309,7 +312,9 @@ export class CoderApi extends Api implements vscode.Disposable {
309312
});
310313

311314
this.attachStreamLogger(ws);
312-
return ws;
315+
316+
// Wait for connection to open before returning
317+
return await this.waitForOpen(ws);
313318
}
314319

315320
private attachStreamLogger<TData>(
@@ -349,9 +354,8 @@ export class CoderApi extends Api implements vscode.Disposable {
349354
): Promise<UnidirectionalStream<ServerSentEvent>> {
350355
const { fallbackApiRoute, ...socketConfigs } = configs;
351356
try {
352-
const ws =
353-
await this.createOneWayWebSocket<ServerSentEvent>(socketConfigs);
354-
return await this.waitForOpen(ws);
357+
// createOneWayWebSocket already waits for open
358+
return await this.createOneWayWebSocket<ServerSentEvent>(socketConfigs);
355359
} catch (error) {
356360
if (this.is404Error(error)) {
357361
this.output.warn(
@@ -396,10 +400,11 @@ export class CoderApi extends Api implements vscode.Disposable {
396400

397401
/**
398402
* Wait for a connection to open. Rejects on error.
403+
* Preserves the specific connection type (e.g., OneWayWebSocket, SseConnection).
399404
*/
400-
private waitForOpen<TData>(
401-
connection: UnidirectionalStream<TData>,
402-
): Promise<UnidirectionalStream<TData>> {
405+
private waitForOpen<T extends UnidirectionalStream<unknown>>(
406+
connection: T,
407+
): Promise<T> {
403408
return new Promise((resolve, reject) => {
404409
const cleanup = () => {
405410
connection.removeEventListener("open", handleOpen);
@@ -414,7 +419,10 @@ export class CoderApi extends Api implements vscode.Disposable {
414419
const handleError = (event: ErrorEvent) => {
415420
cleanup();
416421
connection.close();
417-
const error = toError(event.error, "WebSocket connection error");
422+
const error = toError(
423+
event.error,
424+
event.message || "WebSocket connection error",
425+
);
418426
reject(error);
419427
};
420428

@@ -440,7 +448,15 @@ export class CoderApi extends Api implements vscode.Disposable {
440448
const reconnectingSocket = await ReconnectingWebSocket.create<TData>(
441449
socketFactory,
442450
this.output,
443-
undefined,
451+
{
452+
onCertificateRefreshNeeded: async () => {
453+
const refreshCommand = getRefreshCommand();
454+
if (!refreshCommand) {
455+
return false;
456+
}
457+
return refreshCertificates(refreshCommand, this.output);
458+
},
459+
},
444460
() => this.reconnectingSockets.delete(reconnectingSocket),
445461
);
446462

@@ -479,16 +495,25 @@ function setupInterceptors(client: CoderApi, output: Logger): void {
479495
return config;
480496
});
481497

482-
// Wrap certificate errors.
498+
// Wrap certificate errors and handle client certificate errors with refresh.
483499
client.getAxiosInstance().interceptors.response.use(
484500
(r) => r,
485-
async (err) => {
501+
async (err: unknown) => {
502+
const retryResponse = await tryRefreshClientCertificate(
503+
err,
504+
client.getAxiosInstance(),
505+
output,
506+
);
507+
if (retryResponse) {
508+
return retryResponse;
509+
}
510+
511+
// Handle other certificate errors.
486512
const baseUrl = client.getAxiosInstance().defaults.baseURL;
487513
if (baseUrl) {
488-
throw await CertificateError.maybeWrap(err, baseUrl, output);
489-
} else {
490-
throw err;
514+
throw await ServerCertificateError.maybeWrap(err, baseUrl, output);
491515
}
516+
throw err;
492517
},
493518
);
494519
}
@@ -536,6 +561,61 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) {
536561
);
537562
}
538563

564+
/**
565+
* Attempts to refresh client certificates and retry the request if the error
566+
* is a refreshable client certificate error.
567+
*
568+
* @returns The retry response if refresh succeeds, or undefined if the error
569+
* is not a client certificate error (caller should handle).
570+
* @throws {ClientCertificateError} If this is a client certificate error.
571+
*/
572+
async function tryRefreshClientCertificate(
573+
err: unknown,
574+
axiosInstance: AxiosInstance,
575+
output: Logger,
576+
): Promise<unknown> {
577+
const certError = ClientCertificateError.fromError(err);
578+
if (!certError) {
579+
return undefined;
580+
}
581+
582+
const refreshCommand = getRefreshCommand();
583+
if (
584+
!certError.isRefreshable ||
585+
!refreshCommand ||
586+
!isAxiosError(err) ||
587+
!err.config
588+
) {
589+
throw certError;
590+
}
591+
592+
// _certRetried is per-request (Axios creates fresh config per request).
593+
const config = err.config as RequestConfigWithMeta & {
594+
_certRetried?: boolean;
595+
};
596+
if (config._certRetried) {
597+
throw certError;
598+
}
599+
config._certRetried = true;
600+
601+
output.info(
602+
`Client certificate error (alert ${certError.alertCode}), attempting refresh...`,
603+
);
604+
const success = await refreshCertificates(refreshCommand, output);
605+
if (!success) {
606+
throw certError;
607+
}
608+
609+
// Create new agent with refreshed certificates.
610+
const agent = await createHttpAgent(vscode.workspace.getConfiguration());
611+
config.httpsAgent = agent;
612+
config.httpAgent = agent;
613+
614+
// Retry the request.
615+
output.info("Retrying request with refreshed certificates...");
616+
return axiosInstance.request(config);
617+
}
618+
539619
function wrapRequestTransform(
540620
transformer: AxiosResponseTransformer | AxiosResponseTransformer[],
541621
config: RequestConfigWithMeta,

src/command/exec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as cp from "node:child_process";
2+
import * as util from "node:util";
3+
4+
import { type Logger } from "../logging/logger";
5+
6+
interface ExecException {
7+
code?: number;
8+
stderr?: string;
9+
stdout?: string;
10+
}
11+
12+
function isExecException(err: unknown): err is ExecException {
13+
return (err as ExecException).code !== undefined;
14+
}
15+
16+
export interface ExecCommandOptions {
17+
env?: NodeJS.ProcessEnv;
18+
/** Title for logging (e.g., "Header command", "Certificate refresh"). */
19+
title?: string;
20+
}
21+
22+
export type ExecCommandResult =
23+
| { success: true; stdout: string; stderr: string }
24+
| { success: false; stdout?: string; stderr?: string; exitCode?: number };
25+
26+
/**
27+
* Execute a shell command and return result with success/failure.
28+
* Handles errors gracefully and logs appropriately.
29+
*/
30+
export async function execCommand(
31+
command: string,
32+
logger: Logger,
33+
options?: ExecCommandOptions,
34+
): Promise<ExecCommandResult> {
35+
const title = options?.title ?? "Command";
36+
logger.debug(`Executing ${title}: ${command}`);
37+
38+
try {
39+
const result = await util.promisify(cp.exec)(command, {
40+
env: options?.env,
41+
});
42+
logger.debug(`${title} completed successfully`);
43+
if (result.stdout) {
44+
logger.debug(`${title} stdout:`, result.stdout);
45+
}
46+
if (result.stderr) {
47+
logger.debug(`${title} stderr:`, result.stderr);
48+
}
49+
return {
50+
success: true,
51+
stdout: result.stdout,
52+
stderr: result.stderr,
53+
};
54+
} catch (error) {
55+
if (isExecException(error)) {
56+
logger.warn(`${title} failed with exit code ${error.code}`);
57+
if (error.stdout) {
58+
logger.warn(`${title} stdout:`, error.stdout);
59+
}
60+
if (error.stderr) {
61+
logger.warn(`${title} stderr:`, error.stderr);
62+
}
63+
return {
64+
success: false,
65+
stdout: error.stdout,
66+
stderr: error.stderr,
67+
exitCode: error.code,
68+
};
69+
}
70+
71+
logger.warn(`${title} failed:`, error);
72+
return { success: false };
73+
}
74+
}

0 commit comments

Comments
 (0)