Skip to content

Commit 546c34c

Browse files
committed
Add automatic TLS client certificate refresh
Add new setting `coder.tlsCertRefreshCommand` that specifies a command to run when TLS client certificates expire (e.g., `metatron refresh`). When configured, the extension automatically executes the command and retries failed requests. - Detect certificate expiration errors (SSLV3_ALERT_CERTIFICATE_EXPIRED) - Retry HTTP requests with refreshed certificates on expiration - Handle WebSocket certificate expiration with refresh and reconnect - Split CertificateError into ServerCertificateError and ClientCertificateError - Extract shared command execution logic into execCommand utility
1 parent ab2c9fe commit 546c34c

16 files changed

Lines changed: 668 additions & 229 deletions

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: 49 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,48 @@ function setupInterceptors(client: CoderApi, output: Logger): void {
479489
return config;
480490
});
481491

482-
// Wrap certificate errors.
492+
// Wrap certificate errors and handle certificate expiration with refresh.
483493
client.getAxiosInstance().interceptors.response.use(
484494
(r) => r,
485-
async (err) => {
495+
async (err: unknown) => {
496+
// Check for certificate expiration error first.
497+
if (ClientCertificateError.isExpiredError(err)) {
498+
const axiosErr = err as {
499+
config?: RequestConfigWithMeta & { __certRetried?: boolean };
500+
};
501+
const config = axiosErr.config;
502+
const refreshCommand = getRefreshCommand();
503+
504+
// Only retry once per request.
505+
if (refreshCommand && config && !config.__certRetried) {
506+
config.__certRetried = true;
507+
508+
output.info("Certificate expired, attempting refresh...");
509+
const success = await refreshCertificates(refreshCommand, output);
510+
if (success) {
511+
// Create new agent with refreshed certificates.
512+
const agent = await createHttpAgent(
513+
vscode.workspace.getConfiguration(),
514+
);
515+
config.httpsAgent = agent;
516+
config.httpAgent = agent;
517+
518+
// Retry the request.
519+
output.info("Retrying request with refreshed certificates...");
520+
return client.getAxiosInstance().request(config);
521+
}
522+
}
523+
524+
// Throw user-friendly error.
525+
throw new ClientCertificateError(toError(err), !refreshCommand);
526+
}
527+
528+
// Handle other certificate errors.
486529
const baseUrl = client.getAxiosInstance().defaults.baseURL;
487530
if (baseUrl) {
488-
throw await CertificateError.maybeWrap(err, baseUrl, output);
489-
} else {
490-
throw err;
531+
throw await ServerCertificateError.maybeWrap(err, baseUrl, output);
491532
}
533+
throw err;
492534
},
493535
);
494536
}

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+
}

src/error/certificateError.ts

Lines changed: 12 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,14 @@
1-
import {
2-
X509Certificate,
3-
KeyUsagesExtension,
4-
KeyUsageFlags,
5-
} from "@peculiar/x509";
6-
import { isAxiosError } from "axios";
7-
import * as tls from "node:tls";
8-
import * as vscode from "vscode";
9-
10-
import { type Logger } from "../logging/logger";
11-
12-
import { toError } from "./errorUtils";
13-
14-
// X509_ERR_CODE represents error codes as returned from BoringSSL/OpenSSL.
15-
export enum X509_ERR_CODE {
16-
UNABLE_TO_VERIFY_LEAF_SIGNATURE = "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
17-
DEPTH_ZERO_SELF_SIGNED_CERT = "DEPTH_ZERO_SELF_SIGNED_CERT",
18-
SELF_SIGNED_CERT_IN_CHAIN = "SELF_SIGNED_CERT_IN_CHAIN",
19-
}
20-
21-
// X509_ERR contains human-friendly versions of TLS errors.
22-
export enum X509_ERR {
23-
PARTIAL_CHAIN = "Your Coder deployment's certificate cannot be verified because a certificate is missing from its chain. To fix this your deployment's administrator must bundle the missing certificates.",
24-
// NON_SIGNING can be removed if BoringSSL is patched and the patch makes it
25-
// into the version of Electron used by VS Code.
26-
NON_SIGNING = "Your Coder deployment's certificate is not marked as being capable of signing. VS Code uses a version of Electron that does not support certificates like this even if they are self-issued. The certificate must be regenerated with the certificate signing capability.",
27-
UNTRUSTED_LEAF = "Your Coder deployment's certificate does not appear to be trusted by this system. The certificate must be added to this system's trust store.",
28-
UNTRUSTED_CHAIN = "Your Coder deployment's certificate chain does not appear to be trusted by this system. The root of the certificate chain must be added to this system's trust store. ",
29-
}
30-
31-
export class CertificateError extends Error {
32-
public static readonly ActionAllowInsecure = "Allow Insecure";
33-
public static readonly ActionOK = "OK";
34-
public static readonly InsecureMessage =
35-
'The Coder extension will no longer verify TLS on HTTPS requests. You can change this at any time with the "coder.insecure" property in your VS Code settings.';
36-
37-
private constructor(
38-
message: string,
39-
public readonly x509Err?: X509_ERR,
40-
) {
41-
super("Secure connection to your Coder deployment failed: " + message);
42-
}
43-
44-
// maybeWrap returns a CertificateError if the code is a certificate error
45-
// otherwise it returns the original error.
46-
static async maybeWrap<T>(
47-
err: T,
48-
address: string,
49-
logger: Logger,
50-
): Promise<CertificateError | T> {
51-
if (isAxiosError(err)) {
52-
switch (err.code) {
53-
case X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE:
54-
// "Unable to verify" can mean different things so we will attempt to
55-
// parse the certificate and determine which it is.
56-
try {
57-
const cause =
58-
await CertificateError.determineVerifyErrorCause(address);
59-
return new CertificateError(err.message, cause);
60-
} catch (error) {
61-
logger.warn(`Failed to parse certificate from ${address}`, error);
62-
break;
63-
}
64-
case X509_ERR_CODE.DEPTH_ZERO_SELF_SIGNED_CERT:
65-
return new CertificateError(err.message, X509_ERR.UNTRUSTED_LEAF);
66-
case X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN:
67-
return new CertificateError(err.message, X509_ERR.UNTRUSTED_CHAIN);
68-
case undefined:
69-
break;
70-
}
71-
}
72-
return err;
73-
}
74-
75-
// determineVerifyErrorCause fetches the certificate(s) from the specified
76-
// address, parses the leaf, and returns the reason the certificate is giving
77-
// an "unable to verify" error or throws if unable to figure it out.
78-
private static async determineVerifyErrorCause(
79-
address: string,
80-
): Promise<X509_ERR> {
81-
return new Promise((resolve, reject) => {
82-
try {
83-
const url = new URL(address);
84-
const socket = tls.connect(
85-
{
86-
port: Number.parseInt(url.port, 10) || 443,
87-
host: url.hostname,
88-
rejectUnauthorized: false,
89-
},
90-
() => {
91-
const x509 = socket.getPeerX509Certificate();
92-
socket.destroy();
93-
if (!x509) {
94-
throw new Error("no peer certificate");
95-
}
96-
97-
// We use "@peculiar/x509" because Node's x509 returns an undefined `keyUsage`.
98-
const cert = new X509Certificate(x509.toString());
99-
const isSelfIssued = cert.subject === cert.issuer;
100-
if (!isSelfIssued) {
101-
return resolve(X509_ERR.PARTIAL_CHAIN);
102-
}
103-
104-
// The key usage needs to exist but not have cert signing to fail.
105-
const extension = cert.getExtension(KeyUsagesExtension);
106-
if (extension) {
107-
const hasKeyCertSign =
108-
extension.usages & KeyUsageFlags.keyCertSign;
109-
if (!hasKeyCertSign) {
110-
return resolve(X509_ERR.NON_SIGNING);
111-
}
112-
}
113-
// This branch is currently untested; it does not appear possible to
114-
// get the error "unable to verify" with a self-signed certificate
115-
// unless the key usage was the issue since it would have errored
116-
// with "self-signed certificate" instead.
117-
return resolve(X509_ERR.UNTRUSTED_LEAF);
118-
},
119-
);
120-
socket.on("error", (err) => reject(toError(err)));
121-
} catch (err) {
122-
reject(toError(err));
123-
}
124-
});
125-
}
126-
127-
// allowInsecure updates the value of the "coder.insecure" property.
128-
private allowInsecure(): void {
129-
vscode.workspace
130-
.getConfiguration()
131-
.update("coder.insecure", true, vscode.ConfigurationTarget.Global);
132-
vscode.window.showInformationMessage(CertificateError.InsecureMessage);
133-
}
134-
135-
async showModal(title: string): Promise<void> {
136-
return this.showNotification(title, {
137-
detail: this.x509Err || this.message,
138-
modal: true,
139-
useCustom: true,
140-
});
141-
}
142-
143-
async showNotification(
1+
/**
2+
* Base class for certificate-related errors that can display notifications to users.
3+
* Use `instanceof CertificateError` to check if an error is a certificate error.
4+
*/
5+
export abstract class CertificateError extends Error {
6+
/** Human-friendly detail message for display */
7+
public abstract readonly detail: string;
8+
9+
/** Show error notification. Pass { modal: true } for modal dialogs. */
10+
public abstract showNotification(
14411
title?: string,
145-
options: vscode.MessageOptions = {},
146-
): Promise<void> {
147-
const val = await vscode.window.showErrorMessage(
148-
title || this.x509Err || this.message,
149-
options,
150-
// TODO: The insecure setting does not seem to work, even though it
151-
// should, as proven by the tests. Even hardcoding rejectUnauthorized to
152-
// false does not work; something seems to just be different when ran
153-
// inside VS Code. Disabling the "Strict SSL" setting does not help
154-
// either. For now avoid showing the button until this is sorted.
155-
// CertificateError.ActionAllowInsecure,
156-
CertificateError.ActionOK,
157-
);
158-
switch (val) {
159-
case CertificateError.ActionOK:
160-
case undefined:
161-
return;
162-
}
163-
}
12+
options?: { modal?: boolean },
13+
): Promise<void>;
16414
}

0 commit comments

Comments
 (0)