diff --git a/package.json b/package.json index 1b0829fa..7c63f0b6 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,11 @@ }, "scope": "machine", "default": [] + }, + "coder.insecure": { + "markdownDescription": "If true, the extension will not verify the authenticity of the remote host. This is useful for self-signed certificates.", + "type": "boolean", + "default": false } } }, @@ -241,4 +246,4 @@ "yaml": "^1.10.0", "zod": "^3.21.4" } -} +} \ No newline at end of file diff --git a/src/commands.ts b/src/commands.ts index 23c03d11..079aac20 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,6 +3,7 @@ import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "cod import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" import * as vscode from "vscode" import { extractAgents } from "./api-helper" +import { SelfSignedCertificateError } from "./error" import { Remote } from "./remote" import { Storage } from "./storage" import { OpenableTreeItem } from "./workspacesProvider" @@ -61,6 +62,14 @@ export class Commands { if (axios.isAxiosError(err) && err.response?.data) { message = err.response.data.detail } + if (err instanceof SelfSignedCertificateError) { + err.showInsecureNotification(this.storage) + + return { + message: err.message, + severity: vscode.InputBoxValidationSeverity.Error, + } + } return { message: "Invalid session token! (" + message + ")", severity: vscode.InputBoxValidationSeverity.Error, @@ -189,7 +198,10 @@ export class Commands { quickPick.items = items quickPick.busy = false }) - .catch(() => { + .catch((ex) => { + if (ex instanceof SelfSignedCertificateError) { + ex.showInsecureNotification(this.storage) + } return }) }) diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 00000000..4c247836 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,51 @@ +import * as fs from "fs/promises" +import * as jsonc from "jsonc-parser" +import * as vscode from "vscode" +import { Storage } from "./storage" + +export class SelfSignedCertificateError extends Error { + public static Notification = + "Your Coder deployment is using a self-signed certificate. VS Code uses a version of Electron that does not support registering self-signed intermediate certificates with extensions." + public static ActionAllowInsecure = "Allow Insecure" + public static ActionViewMoreDetails = "View More Details" + + constructor(message: string) { + super(`Your Coder deployment is using a self-signed certificate: ${message}`) + } + + public viewMoreDetails(): Thenable { + return vscode.env.openExternal(vscode.Uri.parse("https://github.com/coder/vscode-coder/issues/105")) + } + + // allowInsecure manually reads the settings file and updates the value of the + // "coder.insecure" property. + public async allowInsecure(storage: Storage): Promise { + let settingsContent = "{}" + try { + settingsContent = await fs.readFile(storage.getUserSettingsPath(), "utf8") + } catch (ex) { + // Ignore! It's probably because the file doesn't exist. + } + const edits = jsonc.modify(settingsContent, ["coder.insecure"], true, {}) + await fs.writeFile(storage.getUserSettingsPath(), jsonc.applyEdits(settingsContent, edits)) + + vscode.window.showInformationMessage( + '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.', + ) + } + + public async showInsecureNotification(storage: Storage): Promise { + const value = await vscode.window.showErrorMessage( + SelfSignedCertificateError.Notification, + SelfSignedCertificateError.ActionAllowInsecure, + SelfSignedCertificateError.ActionViewMoreDetails, + ) + if (value === SelfSignedCertificateError.ActionViewMoreDetails) { + await this.viewMoreDetails() + return + } + if (value === SelfSignedCertificateError.ActionAllowInsecure) { + return this.allowInsecure(storage) + } + } +} diff --git a/src/extension.ts b/src/extension.ts index e88928d6..b5aaf62c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,13 +1,67 @@ "use strict" +import axios from "axios" import { getAuthenticatedUser } from "coder/site/src/api/api" +import * as https from "https" import * as module from "module" import * as vscode from "vscode" import { Commands } from "./commands" +import { SelfSignedCertificateError } from "./error" import { Remote } from "./remote" import { Storage } from "./storage" import { WorkspaceQuery, WorkspaceProvider } from "./workspacesProvider" export async function activate(ctx: vscode.ExtensionContext): Promise { + // The Remote SSH extension's proposed APIs are used to override + // the SSH host name in VS Code itself. It's visually unappealing + // having a lengthy name! + // + // This is janky, but that's alright since it provides such minimal + // functionality to the extension. + const remoteSSHExtension = vscode.extensions.getExtension("ms-vscode-remote.remote-ssh") + if (!remoteSSHExtension) { + throw new Error("Remote SSH extension not found") + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vscodeProposed: typeof vscode = (module as any)._load( + "vscode", + { + filename: remoteSSHExtension?.extensionPath, + }, + false, + ) + + // updateInsecure is called on extension activation and when the insecure + // setting is changed. It updates the https agent to allow self-signed + // certificates if the insecure setting is true. + const applyInsecure = () => { + const insecure = Boolean(vscode.workspace.getConfiguration().get("coder.insecure")) + + axios.defaults.httpsAgent = new https.Agent({ + // rejectUnauthorized defaults to true, so we need to explicitly set it to false + // if we want to allow self-signed certificates. + rejectUnauthorized: !insecure, + }) + } + + axios.interceptors.response.use( + (r) => r, + (err) => { + if (err) { + const msg = err.toString() as string + if (msg.indexOf("unable to verify the first certificate") !== -1) { + throw new SelfSignedCertificateError(msg) + } + } + + throw err + }, + ) + + vscode.workspace.onDidChangeConfiguration((e) => { + e.affectsConfiguration("coder.insecure") && applyInsecure() + }) + applyInsecure() + const output = vscode.window.createOutputChannel("Coder") const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri) await storage.init() @@ -62,25 +116,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }) - // The Remote SSH extension's proposed APIs are used to override - // the SSH host name in VS Code itself. It's visually unappealing - // having a lengthy name! - // - // This is janky, but that's alright since it provides such minimal - // functionality to the extension. - const remoteSSHExtension = vscode.extensions.getExtension("ms-vscode-remote.remote-ssh") - if (!remoteSSHExtension) { - throw new Error("Remote SSH extension not found") - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const vscodeProposed: typeof vscode = (module as any)._load( - "vscode", - { - filename: remoteSSHExtension?.extensionPath, - }, - false, - ) - const commands = new Commands(vscodeProposed, storage) vscode.commands.registerCommand("coder.login", commands.login.bind(commands)) @@ -109,6 +144,27 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { try { await remote.setup(vscodeProposed.env.remoteAuthority) } catch (ex) { + if (ex instanceof SelfSignedCertificateError) { + const prompt = await vscodeProposed.window.showErrorMessage( + "Failed to open workspace", + { + detail: SelfSignedCertificateError.Notification, + modal: true, + useCustom: true, + }, + SelfSignedCertificateError.ActionAllowInsecure, + SelfSignedCertificateError.ActionViewMoreDetails, + ) + if (prompt === SelfSignedCertificateError.ActionAllowInsecure) { + await ex.allowInsecure(storage) + await remote.reloadWindow() + return + } + if (prompt === SelfSignedCertificateError.ActionViewMoreDetails) { + await ex.viewMoreDetails() + return + } + } await vscodeProposed.window.showErrorMessage("Failed to open workspace", { detail: (ex as string).toString(), modal: true, diff --git a/src/remote.ts b/src/remote.ts index c97430e1..5d2f1134 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -696,7 +696,7 @@ export class Remote { } // reloadWindow reloads the current window. - private async reloadWindow() { + public async reloadWindow() { await vscode.commands.executeCommand("workbench.action.reloadWindow") } diff --git a/src/sshSupport.test.ts b/src/sshSupport.test.ts index dbcde14f..8bf2ac58 100644 --- a/src/sshSupport.test.ts +++ b/src/sshSupport.test.ts @@ -3,6 +3,7 @@ import { computeSSHProperties, sshSupportsSetEnv, sshVersionSupportsSetEnv } fro const supports = { "OpenSSH_8.9p1 Ubuntu-3ubuntu0.1, OpenSSL 3.0.2 15 Mar 2022": true, + "OpenSSH_for_Windows_8.1p1, LibreSSL 3.0.2": true, "OpenSSH_9.0p1, LibreSSL 3.3.6": true, "OpenSSH_7.6p1 Ubuntu-4ubuntu0.7, OpenSSL 1.0.2n 7 Dec 2017": false, "OpenSSH_7.4p1, OpenSSL 1.0.2k-fips 26 Jan 2017": false, diff --git a/src/sshSupport.ts b/src/sshSupport.ts index 5726070a..12b65dd3 100644 --- a/src/sshSupport.ts +++ b/src/sshSupport.ts @@ -16,7 +16,7 @@ export function sshSupportsSetEnv(): boolean { // // It was introduced in SSH 7.8 and not all versions support it. export function sshVersionSupportsSetEnv(sshVersionString: string): boolean { - const match = sshVersionString.match(/OpenSSH_([\d.]+)[^,]*/) + const match = sshVersionString.match(/OpenSSH.*_([\d.]+)[^,]*/) if (match && match[1]) { const installedVersion = match[1] const parts = installedVersion.split(".")