diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ff3aa..0e0520b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- Adds proxy support to network requests + - By default, uses a proxy configuration based on VS Code settings or OS configuration + - Adds a `gitlens.proxy` setting to specify a GitLens specific proxy configuration + ### Changed - Changes local repositories to be considered public rather than private for GitLens+ features (so only a free account would be required) diff --git a/package.json b/package.json index 41de975..69ab104 100644 --- a/package.json +++ b/package.json @@ -3261,6 +3261,40 @@ "scope": "window", "order": 50 }, + "gitlens.proxy": { + "type": [ + "object", + "null" + ], + "default": null, + "items": { + "type": "object", + "required": [ + "url", + "strictSSL" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Specifies the url of the proxy server to use" + }, + "strictSSL": { + "type": "boolean", + "description": "Specifies whether the proxy server certificate should be verified against the list of supplied CAs", + "default": true + } + }, + "additionalProperties": false + }, + "uniqueItems": true, + "description": "Specifies the proxy configuration to use. If not specified, the proxy configuration will be determined based on VS Code or OS settings", + "scope": "window", + "order": 55 + }, "gitlens.plusFeatures.enabled": { "type": "boolean", "default": true, @@ -11035,6 +11069,7 @@ "ansi-regex": "6.0.1", "billboard.js": "3.3.2", "chroma-js": "2.3.0", + "https-proxy-agent": "5.0.0", "iconv-lite": "0.6.3", "lodash-es": "4.17.21", "md5.js": "1.3.5", diff --git a/src/config.ts b/src/config.ts index e58b539..8584a70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -116,6 +116,10 @@ export interface Config { plusFeatures: { enabled: boolean; }; + proxy: { + url: string | null; + strictSSL: boolean; + } | null; remotes: RemotesConfig[] | null; showWelcomeOnInstall: boolean; showWhatsNewAfterUpgrades: boolean; diff --git a/src/container.ts b/src/container.ts index 6376873..b10d504 100644 --- a/src/container.ts +++ b/src/container.ts @@ -366,7 +366,9 @@ export class Container { private async _loadGitHubApi() { try { - return new (await import(/* webpackChunkName: "github" */ './plus/github/github')).GitHubApi(); + const github = new (await import(/* webpackChunkName: "github" */ './plus/github/github')).GitHubApi(this); + this.context.subscriptions.push(github); + return github; } catch (ex) { Logger.error(ex); return undefined; diff --git a/src/env/browser/fetch.ts b/src/env/browser/fetch.ts index f7a0389..c8f9511 100644 --- a/src/env/browser/fetch.ts +++ b/src/env/browser/fetch.ts @@ -1,7 +1,17 @@ const fetch = globalThis.fetch; export { fetch }; +declare global { + interface RequestInit { + agent?: undefined; + } +} + declare type _BodyInit = BodyInit; declare type _RequestInit = RequestInit; declare type _Response = Response; export type { _BodyInit as BodyInit, _RequestInit as RequestInit, _Response as Response }; + +export function getProxyAgent(): undefined { + return undefined; +} diff --git a/src/env/node/fetch.ts b/src/env/node/fetch.ts index 296f3bd..0090f6e 100644 --- a/src/env/node/fetch.ts +++ b/src/env/node/fetch.ts @@ -1,4 +1,43 @@ +import * as url from 'url'; +import { HttpsProxyAgent } from 'https-proxy-agent'; import fetch from 'node-fetch'; +import { configuration } from '../../configuration'; export { fetch }; export type { BodyInit, RequestInit, Response } from 'node-fetch'; + +export function getProxyAgent(): HttpsProxyAgent | undefined { + let strictSSL: boolean | undefined; + let proxyUrl: string | undefined; + + const proxy = configuration.get('proxy'); + if (proxy != null) { + proxyUrl = proxy.url ?? undefined; + strictSSL = proxy.strictSSL; + } else { + const proxySupport = configuration.getAny<'off' | 'on' | 'override' | 'fallback'>( + 'http.proxySupport', + undefined, + 'override', + ); + if (proxySupport === 'off') return undefined; + + strictSSL = configuration.getAny('http.proxyStrictSSL', undefined, true); + proxyUrl = configuration.getAny('http.proxy') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + } + + if (proxyUrl) { + return new HttpsProxyAgent({ + ...url.parse(proxyUrl), + rejectUnauthorized: strictSSL, + }); + } + + if (!strictSSL) { + return new HttpsProxyAgent({ + rejectUnauthorized: false, + }); + } + + return undefined; +} diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 22f35de..a32c601 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -15,7 +15,7 @@ import { workspace, WorkspaceFolder, } from 'vscode'; -import { fetch } from '@env/fetch'; +import { fetch, getProxyAgent } from '@env/fetch'; import { hrtime } from '@env/hrtime'; import { isLinux, isWindows } from '@env/platform'; import type { @@ -480,7 +480,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // Check if the url returns a 200 status code try { - const response = await fetch(url, { method: 'HEAD' }); + const response = await fetch(url, { method: 'HEAD', agent: getProxyAgent() }); if (response.status === 200) { return RepositoryVisibility.Public; } diff --git a/src/plus/github/github.ts b/src/plus/github/github.ts index 4316665..9a186cf 100644 --- a/src/plus/github/github.ts +++ b/src/plus/github/github.ts @@ -2,9 +2,12 @@ import { Octokit } from '@octokit/core'; import { GraphqlResponseError } from '@octokit/graphql'; import { RequestError } from '@octokit/request-error'; import type { Endpoints, OctokitResponse, RequestParameters } from '@octokit/types'; -import { Event, EventEmitter, window } from 'vscode'; -import { fetch } from '@env/fetch'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; +import { Disposable, Event, EventEmitter, window } from 'vscode'; +import { fetch, getProxyAgent } from '@env/fetch'; import { isWeb } from '@env/platform'; +import { configuration } from '../../configuration'; +import type { Container } from '../../container'; import { AuthenticationError, AuthenticationErrorReason, @@ -31,12 +34,47 @@ import { Stopwatch } from '../../system/stopwatch'; const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] }); -export class GitHubApi { +export class GitHubApi implements Disposable { private readonly _onDidReauthenticate = new EventEmitter(); get onDidReauthenticate(): Event { return this._onDidReauthenticate.event; } + private _disposable: Disposable | undefined; + + constructor(_container: Container) { + if (isWeb) return; + + this._disposable = Disposable.from( + configuration.onDidChange(e => { + if (configuration.changed(e, 'proxy')) { + this._proxyAgent = null; + this._octokits.clear(); + } + }), + configuration.onDidChangeAny(e => { + if (e.affectsConfiguration('http.proxy') || e.affectsConfiguration('http.proxyStrictSSL')) { + this._proxyAgent = null; + this._octokits.clear(); + } + }), + ); + } + + dispose(): void { + this._disposable?.dispose(); + } + + private _proxyAgent: HttpsProxyAgent | null | undefined = null; + private get proxyAgent(): HttpsProxyAgent | undefined { + if (isWeb) return undefined; + + if (this._proxyAgent === null) { + this._proxyAgent = getProxyAgent(); + } + return this._proxyAgent; + } + @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( provider: RichRemoteProvider, @@ -1670,7 +1708,7 @@ export class GitHubApi { request: { fetch: fetchCore }, }); } else { - defaults = Octokit.defaults({ auth: `token ${token}` }); + defaults = Octokit.defaults({ auth: `token ${token}`, request: { agent: this.proxyAgent } }); } octokit = new defaults(options); diff --git a/src/plus/subscription/serverConnection.ts b/src/plus/subscription/serverConnection.ts index f884025..0924332 100644 --- a/src/plus/subscription/serverConnection.ts +++ b/src/plus/subscription/serverConnection.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from 'uuid'; import { Disposable, env, EventEmitter, StatusBarAlignment, StatusBarItem, Uri, UriHandler, window } from 'vscode'; -import { fetch, Response } from '@env/fetch'; +import { fetch, getProxyAgent, Response } from '@env/fetch'; import { Container } from '../../container'; import { Logger } from '../../logger'; import { debug, log } from '../../system/decorators/log'; @@ -60,6 +60,7 @@ export class ServerConnection implements Disposable { let rsp: Response; try { rsp = await fetch(Uri.joinPath(this.baseApiUri, 'user').toString(), { + agent: getProxyAgent(), headers: { Authorization: `Bearer ${token}`, // TODO: What user-agent should we use? diff --git a/src/plus/subscription/subscriptionService.ts b/src/plus/subscription/subscriptionService.ts index 6ed1b17..5eb79ca 100644 --- a/src/plus/subscription/subscriptionService.ts +++ b/src/plus/subscription/subscriptionService.ts @@ -16,7 +16,7 @@ import { Uri, window, } from 'vscode'; -import { fetch } from '@env/fetch'; +import { fetch, getProxyAgent } from '@env/fetch'; import { getPlatform } from '@env/platform'; import { configuration } from '../../configuration'; import { Commands, ContextKeys } from '../../constants'; @@ -71,6 +71,7 @@ export class SubscriptionService implements Disposable { private _disposable: Disposable; private _subscription!: Subscription; private _statusBarSubscription: StatusBarItem | undefined; + private _validationTimer: ReturnType | undefined; constructor(private readonly container: Container) { this._disposable = Disposable.from( @@ -269,6 +270,11 @@ export class SubscriptionService implements Disposable { @gate() @log() logout(reset: boolean = false): void { + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + this._validationTimer = undefined; + } + this._sessionPromise = undefined; if (this._session != null) { void this.container.subscriptionAuthentication.removeSession(this._session.id); @@ -323,6 +329,7 @@ export class SubscriptionService implements Disposable { try { const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'resend-email').toString(), { method: 'POST', + agent: getProxyAgent(), headers: { Authorization: `Bearer ${session.accessToken}`, 'User-Agent': userAgent, @@ -477,6 +484,7 @@ export class SubscriptionService implements Disposable { const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'gitlens/checkin').toString(), { method: 'POST', + agent: getProxyAgent(), headers: { Authorization: `Bearer ${session.accessToken}`, 'User-Agent': userAgent, @@ -499,22 +507,21 @@ export class SubscriptionService implements Disposable { throw new AccountValidationError('Unable to validate account', ex); } finally { - this.startDailyCheckInTimer(); + this.startDailyValidationTimer(); } } - private _dailyCheckInTimer: ReturnType | undefined; - private startDailyCheckInTimer(): void { - if (this._dailyCheckInTimer != null) { - clearInterval(this._dailyCheckInTimer); + private startDailyValidationTimer(): void { + if (this._validationTimer != null) { + clearInterval(this._validationTimer); } - // Check twice a day to ensure we check in at least once a day - this._dailyCheckInTimer = setInterval(() => { + // Check 4 times a day to ensure we validate at least once a day + this._validationTimer = setInterval(() => { if (this._lastCheckInDate == null || this._lastCheckInDate.getDate() !== new Date().getDate()) { void this.ensureSession(false, true); } - }, 1000 * 60 * 60 * 12); + }, 1000 * 60 * 60 * 6); } @debug()