Skip to content

Commit 4f33303

Browse files
committed
Added support for automatically refreshing TLS client certificates when they fail
- New coder.tlsCertRefreshCommand setting to configure a refresh command (e.g., metatron refresh) - Detects 7 SSL/TLS client certificate alert codes: expired, revoked, bad certificate, unknown, unsupported, unknown CA, and access denied - Classifies errors as "refreshable" (expired, revoked, bad, unknown) vs "non-refreshable" (unsupported, unknown CA, access denied) - Automatically executes refresh command and retries failed HTTP requests and WebSocket connections - Shows user-friendly notifications with appropriate guidance based on error type - Split CertificateError into ServerCertificateError (server cert issues) and ClientCertificateError (client cert issues) - Added execCommand utility for shared command execution with proper logging
1 parent ab2c9fe commit 4f33303

17 files changed

+952
-224
lines changed

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 certificates expire (e.g., `metatron refresh`). 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: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import * as vscode from "vscode";
1717
import { type ClientOptions } from "ws";
1818

1919
import { watchConfigurationChanges } from "../configWatcher";
20-
import { CertificateError } from "../error/certificateError";
20+
import { ClientCertificateError } from "../error/clientCertificateError";
2121
import { toError } from "../error/errorUtils";
22+
import { ServerCertificateError } from "../error/serverCertificateError";
2223
import { getHeaderCommand, getHeaders } from "../headers";
2324
import { EventStreamLogger } from "../logging/eventStreamLogger";
2425
import {
@@ -49,6 +50,7 @@ import {
4950
} from "../websocket/reconnectingWebSocket";
5051
import { SseConnection } from "../websocket/sseConnection";
5152

53+
import { getRefreshCommand, refreshCertificates } from "./certificateRefresh";
5254
import { createHttpAgent } from "./utils";
5355

5456
const coderSessionTokenHeader = "Coder-Session-Token";
@@ -440,7 +442,15 @@ export class CoderApi extends Api implements vscode.Disposable {
440442
const reconnectingSocket = await ReconnectingWebSocket.create<TData>(
441443
socketFactory,
442444
this.output,
443-
undefined,
445+
{
446+
onCertificateExpired: async () => {
447+
const refreshCommand = getRefreshCommand();
448+
if (!refreshCommand) {
449+
return false;
450+
}
451+
return refreshCertificates(refreshCommand, this.output);
452+
},
453+
},
444454
() => this.reconnectingSockets.delete(reconnectingSocket),
445455
);
446456

@@ -479,16 +489,53 @@ function setupInterceptors(client: CoderApi, output: Logger): void {
479489
return config;
480490
});
481491

482-
// Wrap certificate errors.
492+
// Wrap certificate errors and handle client certificate errors with refresh.
483493
client.getAxiosInstance().interceptors.response.use(
484494
(r) => r,
485-
async (err) => {
495+
async (err: unknown) => {
496+
const refreshCommand = getRefreshCommand();
497+
const certError = ClientCertificateError.fromError(err);
498+
if (certError) {
499+
if (certError.isRefreshable && refreshCommand) {
500+
const config = (
501+
err as {
502+
config?: RequestConfigWithMeta & {
503+
_certRetried?: boolean;
504+
};
505+
}
506+
).config;
507+
508+
if (config && !config._certRetried) {
509+
config._certRetried = true;
510+
511+
output.info(
512+
`Client certificate error (alert ${certError.alertCode}), attempting refresh...`,
513+
);
514+
const success = await refreshCertificates(refreshCommand, output);
515+
if (success) {
516+
// Create new agent with refreshed certificates.
517+
const agent = await createHttpAgent(
518+
vscode.workspace.getConfiguration(),
519+
);
520+
config.httpsAgent = agent;
521+
config.httpAgent = agent;
522+
523+
// Retry the request.
524+
output.info("Retrying request with refreshed certificates...");
525+
return client.getAxiosInstance().request(config);
526+
}
527+
}
528+
}
529+
530+
throw certError;
531+
}
532+
533+
// Handle other certificate errors.
486534
const baseUrl = client.getAxiosInstance().defaults.baseURL;
487535
if (baseUrl) {
488-
throw await CertificateError.maybeWrap(err, baseUrl, output);
489-
} else {
490-
throw err;
536+
throw await ServerCertificateError.maybeWrap(err, baseUrl, output);
491537
}
538+
throw err;
492539
},
493540
);
494541
}

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)