From 009bca7989171e78fe88e28c09f068dd515caae9 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 21 Jun 2024 15:03:52 -0800 Subject: [PATCH] Add coder.proxyBypass setting This works around VS Code having no support for no_proxy except through environment variables. --- CHANGELOG.md | 14 +++++-- package.json | 11 ++++-- src/api.ts | 67 ++++++++++++++++++++++---------- src/proxy.ts | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 7 ---- 5 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 src/proxy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c497e215..6dde9eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,23 @@ ## Unreleased +## [v1.2.0](https://github.com/coder/vscode-coder/releases/tag/v1.2.0) (2024-06-21) + +### Added + +- New setting `coder.proxyBypass` which is the equivalent of `no_proxy`. This + only takes effect if `http.proxySupport` is `on` or `off`, otherwise VS Code + overrides the HTTP agent the plugin sets. + ## [v1.1.0](https://github.com/coder/vscode-coder/releases/tag/v1.1.0) (2024-06-17) ### Added - Workspace and agent statuses now show in the sidebar. These are updated every five seconds. -- Support http.proxy setting and proxy environment variables. This only takes - effect if http.proxySupport is `off` or `on`, otherwise VS Code overrides the - HTTP agent the plugin sets. +- Support `http.proxy` setting and proxy environment variables. These only take + effect if `http.proxySupport` is `on` or `off`, otherwise VS Code overrides + the HTTP agent the plugin sets. ## [v1.0.2](https://github.com/coder/vscode-coder/releases/tag/v1.0.2) (2024-06-12) diff --git a/package.json b/package.json index 281c75df..f1c8d945 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "displayName": "Coder", "description": "Open any workspace with a single click.", "repository": "https://github.com/coder/vscode-coder", - "version": "1.1.0", + "version": "1.2.0", "engines": { "vscode": "^1.73.0" }, @@ -87,6 +87,11 @@ "markdownDescription": "Path to file for TLS certificate authority", "type": "string", "default": "" + }, + "coder.proxyBypass": { + "markdownDescription": "If not set, will inherit from the `no_proxy` or `NO_PROXY` environment variables. `http.proxySupport` must be set to `on` or `off`, otherwise VS Code will override the proxy agent set by the plugin.", + "type": "string", + "default": "" } } }, @@ -249,7 +254,6 @@ "@types/glob": "^7.1.3", "@types/ndjson": "^2.0.1", "@types/node": "^18.0.0", - "@types/proxy-from-env": "^1.0.4", "@types/vscode": "^1.73.0", "@types/which": "^2.0.1", "@types/ws": "^8.5.10", @@ -268,7 +272,6 @@ "glob": "^7.1.6", "nyc": "^15.1.0", "prettier": "^3.2.5", - "proxy-agent": "^6.4.0", "ts-loader": "^9.5.1", "tsc-watch": "^6.2.0", "typescript": "^5.4.5", @@ -291,7 +294,7 @@ "ndjson": "^2.0.0", "node-forge": "^1.3.1", "pretty-bytes": "^6.0.0", - "proxy-from-env": "^1.1.0", + "proxy-agent": "^6.4.0", "semver": "^7.6.0", "tar-fs": "^2.1.1", "ua-parser-js": "^1.0.37", diff --git a/src/api.ts b/src/api.ts index 49a9e7d6..3e1eeaa0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,17 +2,59 @@ import { Api } from "coder/site/src/api/api" import fs from "fs/promises" import * as os from "os" import { ProxyAgent } from "proxy-agent" -import { getProxyForUrl } from "proxy-from-env" import * as vscode from "vscode" import { CertificateError } from "./error" +import { getProxyForUrl } from "./proxy" import { Storage } from "./storage" // expandPath will expand ${userHome} in the input string. -const expandPath = (input: string): string => { +function expandPath(input: string): string { const userHome = os.homedir() return input.replace(/\${userHome}/g, userHome) } +async function createHttpAgent(): Promise { + const cfg = vscode.workspace.getConfiguration() + const insecure = Boolean(cfg.get("coder.insecure")) + const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim()) + const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()) + const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()) + + return new ProxyAgent({ + // Called each time a request is made. + getProxyForUrl: (url: string) => { + const cfg = vscode.workspace.getConfiguration() + return getProxyForUrl(url, cfg.get("http.proxy"), cfg.get("coder.proxyBypass")) + }, + cert: certFile === "" ? undefined : await fs.readFile(certFile), + key: keyFile === "" ? undefined : await fs.readFile(keyFile), + ca: caFile === "" ? undefined : await fs.readFile(caFile), + // rejectUnauthorized defaults to true, so we need to explicitly set it to + // false if we want to allow self-signed certificates. + rejectUnauthorized: !insecure, + }) +} + +let agent: Promise | undefined = undefined +async function getHttpAgent(): Promise { + if (!agent) { + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + // http.proxy and coder.proxyBypass are read each time a request is + // made, so no need to watch them. + e.affectsConfiguration("coder.insecure") || + e.affectsConfiguration("coder.tlsCertFile") || + e.affectsConfiguration("coder.tlsKeyFile") || + e.affectsConfiguration("coder.tlsCaFile") + ) { + agent = createHttpAgent() + } + }) + agent = createHttpAgent() + } + return agent +} + /** * Create an sdk instance using the provided URL and token and hook it up to * configuration. The token may be undefined if some other form of @@ -31,25 +73,10 @@ export async function makeCoderSdk(baseUrl: string, token: string | undefined, s config.headers[key] = value }) - const cfg = vscode.workspace.getConfiguration() - const insecure = Boolean(cfg.get("coder.insecure")) - const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim()) - const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()) - const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()) - // Configure proxy and TLS. - const agent = new ProxyAgent({ - // If the proxy setting exists, we always use it. Otherwise we follow the - // standard environment variables (no_proxy, http_proxy, etc). - getProxyForUrl: (url: string) => cfg.get("http.proxy") || getProxyForUrl(url), - cert: certFile === "" ? undefined : await fs.readFile(certFile), - key: keyFile === "" ? undefined : await fs.readFile(keyFile), - ca: caFile === "" ? undefined : await fs.readFile(caFile), - // rejectUnauthorized defaults to true, so we need to explicitly set it to - // false if we want to allow self-signed certificates. - rejectUnauthorized: !insecure, - }) - + // Note that by default VS Code overrides the agent. To prevent this, set + // `http.proxySupport` to `on` or `off`. + const agent = await getHttpAgent() config.httpsAgent = agent config.httpAgent = agent diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 00000000..a4db709e --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,106 @@ +// This file is copied from proxy-from-env with added support to use something +// other than environment variables. + +import { parse as parseUrl } from "url" + +const DEFAULT_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +} + +/** + * @param {string|object} url - The URL, or the result from url.parse. + * @return {string} The URL of the proxy that should handle the request to the + * given URL. If no proxy is set, this will be an empty string. + */ +export function getProxyForUrl( + url: string, + httpProxy: string | null | undefined, + noProxy: string | null | undefined, +): string { + const parsedUrl = typeof url === "string" ? parseUrl(url) : url || {} + let proto = parsedUrl.protocol + let hostname = parsedUrl.host + const portRaw = parsedUrl.port + if (typeof hostname !== "string" || !hostname || typeof proto !== "string") { + return "" // Don't proxy URLs without a valid scheme or host. + } + + proto = proto.split(":", 1)[0] + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, "") + const port = (portRaw && parseInt(portRaw)) || DEFAULT_PORTS[proto] || 0 + if (!shouldProxy(hostname, port, noProxy)) { + return "" // Don't proxy URLs that match NO_PROXY. + } + + let proxy = + httpProxy || + getEnv("npm_config_" + proto + "_proxy") || + getEnv(proto + "_proxy") || + getEnv("npm_config_proxy") || + getEnv("all_proxy") + if (proxy && proxy.indexOf("://") === -1) { + // Missing scheme in proxy, default to the requested URL's scheme. + proxy = proto + "://" + proxy + } + return proxy +} + +/** + * Determines whether a given URL should be proxied. + * + * @param {string} hostname - The host name of the URL. + * @param {number} port - The effective port of the URL. + * @returns {boolean} Whether the given URL should be proxied. + * @private + */ +function shouldProxy(hostname: string, port: number, noProxy: string | null | undefined): boolean { + const NO_PROXY = noProxy || (getEnv("npm_config_no_proxy") || getEnv("no_proxy")).toLowerCase() + if (!NO_PROXY) { + return true // Always proxy if NO_PROXY is not set. + } + if (NO_PROXY === "*") { + return false // Never proxy if wildcard is set. + } + + return NO_PROXY.split(/[,\s]/).every(function (proxy) { + if (!proxy) { + return true // Skip zero-length hosts. + } + const parsedProxy = proxy.match(/^(.+):(\d+)$/) + let parsedProxyHostname = parsedProxy ? parsedProxy[1] : proxy + const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0 + if (parsedProxyPort && parsedProxyPort !== port) { + return true // Skip if ports don't match. + } + + if (!/^[.*]/.test(parsedProxyHostname)) { + // No wildcards, so stop proxying if there is an exact match. + return hostname !== parsedProxyHostname + } + + if (parsedProxyHostname.charAt(0) === "*") { + // Remove leading wildcard. + parsedProxyHostname = parsedProxyHostname.slice(1) + } + // Stop proxying if the hostname ends with the no_proxy host. + return !hostname.endsWith(parsedProxyHostname) + }) +} + +/** + * Get the value for an environment variable. + * + * @param {string} key - The name of the environment variable. + * @return {string} The value of the environment variable. + * @private + */ +function getEnv(key: string): string { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "" +} diff --git a/yarn.lock b/yarn.lock index daf7478b..0cdad9a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -704,13 +704,6 @@ dependencies: undici-types "~5.26.4" -"@types/proxy-from-env@^1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@types/proxy-from-env/-/proxy-from-env-1.0.4.tgz#0a0545768f2d6c16b81a84ffefb53b423807907c" - integrity sha512-TPR9/bCZAr3V1eHN4G3LD3OLicdJjqX1QRXWuNcCYgE66f/K8jO2ZRtHxI2D9MbnuUP6+qiKSS8eUHp6TFHGCw== - dependencies: - "@types/node" "*" - "@types/semver@^7.5.0": version "7.5.3" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.3.tgz#9a726e116beb26c24f1ccd6850201e1246122e04"