From 607a03a95efed789e59aa6bb5509ea3fb641c39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Tron=C3=AD=C4=8Dek?= Date: Tue, 5 Apr 2022 22:50:31 +0100 Subject: [PATCH] Implement gitpod auth and settings sync --- extensions/gitpod/package.json | 53 ++- extensions/gitpod/src/authentication.ts | 272 ++++++++++++ extensions/gitpod/src/common/dispose.ts | 41 ++ extensions/gitpod/src/common/keychain.ts | 49 ++ extensions/gitpod/src/common/logger.ts | 67 +++ extensions/gitpod/src/common/telemetry.ts | 435 ++++++++++++++++++ extensions/gitpod/src/common/utils.ts | 98 ++++ extensions/gitpod/src/extension.ts | 494 +++------------------ extensions/gitpod/src/gitpodServer.ts | 176 ++++++++ extensions/gitpod/src/internalApi.ts | 103 +++++ extensions/gitpod/src/localApp.ts | 456 +++++++++++++++++++ extensions/gitpod/src/settingsSync.ts | 93 ++++ extensions/gitpod/src/telemetryReporter.ts | 46 ++ extensions/gitpod/tsconfig.json | 7 +- extensions/yarn.lock | 62 ++- 15 files changed, 2007 insertions(+), 445 deletions(-) create mode 100644 extensions/gitpod/src/authentication.ts create mode 100644 extensions/gitpod/src/common/dispose.ts create mode 100644 extensions/gitpod/src/common/keychain.ts create mode 100644 extensions/gitpod/src/common/logger.ts create mode 100644 extensions/gitpod/src/common/telemetry.ts create mode 100644 extensions/gitpod/src/common/utils.ts create mode 100644 extensions/gitpod/src/gitpodServer.ts create mode 100644 extensions/gitpod/src/internalApi.ts create mode 100644 extensions/gitpod/src/localApp.ts create mode 100644 extensions/gitpod/src/settingsSync.ts create mode 100644 extensions/gitpod/src/telemetryReporter.ts diff --git a/extensions/gitpod/package.json b/extensions/gitpod/package.json index ada781e7b6da5..138f8d6c5d34a 100644 --- a/extensions/gitpod/package.json +++ b/extensions/gitpod/package.json @@ -29,10 +29,52 @@ ], "activationEvents": [ "*", - "onCommand:gitpod.api.autoTunnel" + "onCommand:gitpod.api.autoTunnel", + "onAuthenticationRequest:gitpod" ], "contributes": { + "authentication": [ + { + "label": "Gitpod", + "id": "gitpod" + } + ], + "configuration": [ + { + "title": "Gitpod", + "properties": { + "gitpod.host": { + "type": "string", + "description": "Gitpod Service URL. Update this if you are using a Gitpod self-hosted installation.", + "default": "https://gitpod.io/", + "scope": "application" + } + } + }, + { + "title": "Settings sync", + "properties": { + "configurationSync.store": { + "type": "object", + "description": "Settings Sync Provider configuration.", + "scope": "application" + } + } + } + ], "commands": [ + { + "command": "gitpod.syncProvider.remove", + "category": "Settings Sync", + "enablement": "gitpod.addedSyncProvider", + "title": "Disable Sign In with Gitpod" + }, + { + "command": "gitpod.syncProvider.add", + "category": "Settings Sync", + "enablement": "!gitpod.addedSyncProvider", + "title": "Enable Sign In with Gitpod" + }, { "command": "gitpod.exportLogs", "category": "Gitpod", @@ -47,17 +89,24 @@ "vscode:prepublish": "npm run compile" }, "devDependencies": { + "@types/analytics-node": "^3.1.8", + "@types/crypto-js": "4.1.1", "@types/node": "16.x", "@types/node-fetch": "^2.5.12", "@types/tmp": "^0.2.1", + "@types/uuid": "8.0.0", + "@types/ws": "^7.2.6", "@types/yazl": "^2.4.2" }, "dependencies": { + "@gitpod/gitpod-protocol": "main", "@gitpod/local-app-api-grpcweb": "main", "@improbable-eng/grpc-web-node-http-transport": "^0.14.0", + "analytics-node": "^6.0.0", "node-fetch": "2.6.7", + "pkce-challenge": "^3.0.0", "tmp": "^0.2.1", - "vscode-nls": "^5.0.0", + "uuid": "8.1.0", "yazl": "^2.5.1" }, "extensionDependencies": [ diff --git a/extensions/gitpod/src/authentication.ts b/extensions/gitpod/src/authentication.ts new file mode 100644 index 0000000000000..5afea5da39796 --- /dev/null +++ b/extensions/gitpod/src/authentication.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { v4 as uuid } from 'uuid'; +import { Keychain } from './common/keychain'; +import { GitpodServer } from './gitpodServer'; +import Log from './common/logger'; +import { arrayEquals } from './common/utils'; +import { Disposable } from './common/dispose'; +import TelemetryReporter from './telemetryReporter'; + +interface SessionData { + id: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; + accessToken: string; +} + +export default class GitpodAuthenticationProvider extends Disposable implements vscode.AuthenticationProvider { + private _sessionChangeEmitter = new vscode.EventEmitter(); + private _logger: Log; + private _telemetry: TelemetryReporter; + private _gitpodServer: GitpodServer; + private _keychain: Keychain; + + private _sessionsPromise: Promise; + + constructor(private readonly context: vscode.ExtensionContext, logger: Log, telemetry: TelemetryReporter) { + super(); + + this._logger = logger; + this._telemetry = telemetry; + + const gitpodHost = vscode.workspace.getConfiguration('gitpod').get('host')!; + const serviceUrl = new URL(gitpodHost); + this._gitpodServer = new GitpodServer(serviceUrl.toString(), this._logger); + this._keychain = new Keychain(this.context, `gitpod.auth.${serviceUrl.hostname}`, this._logger); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('gitpod.host')) { + const gitpodHost = vscode.workspace.getConfiguration('gitpod').get('host')!; + const serviceUrl = new URL(gitpodHost); + this._gitpodServer.dispose(); + this._gitpodServer = new GitpodServer(serviceUrl.toString(), this._logger); + this._keychain = new Keychain(this.context, `gitpod.auth.${serviceUrl.hostname}`, this._logger); + + this.checkForUpdates(); + } + })); + + // Contains the current state of the sessions we have available. + this._sessionsPromise = this.readSessions(); + + this._register(vscode.authentication.registerAuthenticationProvider('gitpod', 'Gitpod', this, { supportsMultipleAccounts: false })); + this._register(this.context.secrets.onDidChange(() => this.checkForUpdates())); + } + + get onDidChangeSessions() { + return this._sessionChangeEmitter.event; + } + + async getSessions(scopes?: string[]): Promise { + // For the Gitpod scope list, order doesn't matter so we immediately sort the scopes + const sortedScopes = scopes?.sort() || []; + this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`); + const sessions = await this._sessionsPromise; + const finalSessions = sortedScopes.length + ? sessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes)) + : sessions; + + this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`); + return finalSessions; + } + + private async checkForUpdates() { + const previousSessions = await this._sessionsPromise; + this._sessionsPromise = this.readSessions(); + const storedSessions = await this._sessionsPromise; + + const added: vscode.AuthenticationSession[] = []; + const removed: vscode.AuthenticationSession[] = []; + + storedSessions.forEach(session => { + const matchesExisting = previousSessions.some(s => s.id === session.id); + // Another window added a session to the keychain, add it to our state as well + if (!matchesExisting) { + this._logger.info('Adding session found in keychain'); + added.push(session); + } + }); + + previousSessions.forEach(session => { + const matchesExisting = storedSessions.some(s => s.id === session.id); + // Another window has logged out, remove from our state + if (!matchesExisting) { + this._logger.info('Removing session no longer found in keychain'); + removed.push(session); + } + }); + + if (added.length || removed.length) { + this._sessionChangeEmitter.fire({ added, removed, changed: [] }); + } + } + + private async readSessions(): Promise { + let sessionData: SessionData[]; + try { + this._logger.info('Reading sessions from keychain...'); + const storedSessions = await this._keychain.getToken(); + if (!storedSessions) { + return []; + } + this._logger.info('Got stored sessions!'); + + try { + sessionData = JSON.parse(storedSessions); + } catch (e) { + await this._keychain.deleteToken(); + throw e; + } + } catch (e) { + this._logger.error(`Error reading token: ${e}`); + return []; + } + + const sessionPromises = sessionData.map(async (session: SessionData) => { + // For the Gitpod scope list, order doesn't matter so we immediately sort the scopes + const sortedScopes = session.scopes.sort(); + const scopesStr = sortedScopes.join(' '); + + let userInfo: { id: string; accountName: string } | undefined; + if (!session.account) { + try { + userInfo = await this._gitpodServer.getUserInfo(session.accessToken); + this._logger.info(`Verified session with the following scopes: ${scopesStr}`); + } catch (e) { + // Remove sessions that return unauthorized response + if (e.message === 'Unauthorized') { + return undefined; + } + } + } + + this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`); + return { + id: session.id, + account: { + label: session.account + ? session.account.label ?? session.account.displayName ?? '' + : userInfo?.accountName ?? '', + id: session.account?.id ?? userInfo?.id ?? '' + }, + scopes: sortedScopes, + accessToken: session.accessToken + }; + }); + + const verifiedSessions = (await Promise.allSettled(sessionPromises)) + .filter(p => p.status === 'fulfilled') + .map(p => (p as PromiseFulfilledResult).value) + .filter((p?: T): p is T => Boolean(p)); + + this._logger.info(`Got ${verifiedSessions.length} verified sessions.`); + if (verifiedSessions.length !== sessionData.length) { + await this.storeSessions(verifiedSessions); + } + + return verifiedSessions; + } + + private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise { + this._logger.info(`Storing ${sessions.length} sessions...`); + this._sessionsPromise = Promise.resolve(sessions); + await this._keychain.setToken(JSON.stringify(sessions)); + this._logger.info(`Stored ${sessions.length} sessions!`); + } + + public async createSession(scopes: string[]): Promise { + try { + // For the Gitpod scope list, order doesn't matter so we immediately sort the scopes + const sortedScopes = scopes.sort(); + + this._telemetry.sendTelemetryEvent('gitpod_desktop_auth', { + kind: 'login', + scopes: JSON.stringify(sortedScopes), + }); + + const scopeString = sortedScopes.join(' '); + const token = await this._gitpodServer.login(scopeString); + const session = await this.tokenToSession(token, sortedScopes); + + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(s => s.id === session.id || arrayEquals([...s.scopes].sort(), sortedScopes)); + if (sessionIndex > -1) { + sessions.splice(sessionIndex, 1, session); + } else { + sessions.push(session); + } + await this.storeSessions(sessions); + + this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] }); + + this._logger.info('Login success!'); + + return session; + } catch (e) { + // If login was cancelled, do not notify user. + if (e === 'Cancelled' || e.message === 'Cancelled') { + this._telemetry.sendTelemetryEvent('gitpod_desktop_auth', { kind: 'login_cancelled' }); + throw e; + } + + this._telemetry.sendTelemetryEvent('gitpod_desktop_auth', { kind: 'login_failed' }); + + vscode.window.showErrorMessage(`Sign in failed: ${e}`); + this._logger.error(e); + throw e; + } + } + + private async tokenToSession(token: string, scopes: string[]): Promise { + const userInfo = await this._gitpodServer.getUserInfo(token); + return { + id: uuid(), + accessToken: token, + account: { label: userInfo.accountName, id: userInfo.id }, + scopes + }; + } + + public async removeSession(id: string) { + try { + this._telemetry.sendTelemetryEvent('gitpod_desktop_auth', { kind: 'logout' }); + + this._logger.info(`Logging out of ${id}`); + + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(session => session.id === id); + if (sessionIndex > -1) { + const session = sessions[sessionIndex]; + sessions.splice(sessionIndex, 1); + + await this.storeSessions(sessions); + + this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] }); + } else { + this._logger.error('Session not found'); + } + } catch (e) { + this._telemetry.sendTelemetryEvent('gitpod_desktop_auth', { kind: 'logout_failed' }); + + vscode.window.showErrorMessage(`Sign out failed: ${e}`); + this._logger.error(e); + throw e; + } + } + + public handleUri(uri: vscode.Uri) { + this._gitpodServer.hadleUri(uri); + } + + public override dispose() { + super.dispose(); + this._gitpodServer.dispose(); + } +} diff --git a/extensions/gitpod/src/common/dispose.ts b/extensions/gitpod/src/common/dispose.ts new file mode 100644 index 0000000000000..7e5fd680eaacd --- /dev/null +++ b/extensions/gitpod/src/common/dispose.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export function disposeAll(disposables: vscode.Disposable[]): void { + while (disposables.length) { + const item = disposables.pop(); + if (item) { + item.dispose(); + } + } +} + +export abstract class Disposable { + private _isDisposed = false; + + protected _disposables: vscode.Disposable[] = []; + + public dispose(): any { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + disposeAll(this._disposables); + } + + protected _register(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/extensions/gitpod/src/common/keychain.ts b/extensions/gitpod/src/common/keychain.ts new file mode 100644 index 0000000000000..cdea18ea3e15a --- /dev/null +++ b/extensions/gitpod/src/common/keychain.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// keytar depends on a native module shipped in vscode, so this is +// how we load it +import * as vscode from 'vscode'; +import Log from './logger'; + +export class Keychain { + constructor( + private readonly context: vscode.ExtensionContext, + private readonly serviceId: string, + private readonly logger: Log + ) { } + + async setToken(token: string): Promise { + try { + return await this.context.secrets.store(this.serviceId, token); + } catch (e) { + // Ignore + this.logger.error(`Setting token failed: ${e}`); + } + } + + async getToken(): Promise { + try { + const secret = await this.context.secrets.get(this.serviceId); + if (secret && secret !== '[]') { + this.logger.trace('Token acquired from secret storage.'); + } + return secret; + } catch (e) { + // Ignore + this.logger.error(`Getting token failed: ${e}`); + return Promise.resolve(undefined); + } + } + + async deleteToken(): Promise { + try { + return await this.context.secrets.delete(this.serviceId); + } catch (e) { + // Ignore + this.logger.error(`Deleting token failed: ${e}`); + return Promise.resolve(undefined); + } + } +} diff --git a/extensions/gitpod/src/common/logger.ts b/extensions/gitpod/src/common/logger.ts new file mode 100644 index 0000000000000..0f71ef88014ae --- /dev/null +++ b/extensions/gitpod/src/common/logger.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +type LogLevel = 'Trace' | 'Info' | 'Error' | 'Warn' | 'Log'; + +export default class Log { + private output: vscode.OutputChannel; + + constructor(name: string) { + this.output = vscode.window.createOutputChannel(name); + } + + private data2String(data: any): string { + if (data instanceof Error) { + return data.stack || data.message; + } + if (data.success === false && data.message) { + return data.message; + } + return data.toString(); + } + + public trace(message: string, data?: any): void { + this.logLevel('Trace', message, data); + } + + public info(message: string, data?: any): void { + this.logLevel('Info', message, data); + } + + public error(message: string, data?: any): void { + this.logLevel('Error', message, data); + } + + public warn(message: string, data?: any): void { + this.logLevel('Warn', message, data); + } + + public log(message: string, data?: any): void { + this.logLevel('Log', message, data); + } + + public logLevel(level: LogLevel, message: string, data?: any): void { + this.output.appendLine(`[${level} - ${this.now()}] ${message}`); + if (data) { + this.output.appendLine(this.data2String(data)); + } + } + + private now(): string { + const now = new Date(); + return padLeft(now.getUTCHours() + '', 2, '0') + + ':' + padLeft(now.getMinutes() + '', 2, '0') + + ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds(); + } + + public show() { + this.output.show(); + } +} + +function padLeft(s: string, n: number, pad = ' ') { + return pad.repeat(Math.max(0, n - s.length)) + s; +} diff --git a/extensions/gitpod/src/common/telemetry.ts b/extensions/gitpod/src/common/telemetry.ts new file mode 100644 index 0000000000000..b9753a53b3d63 --- /dev/null +++ b/extensions/gitpod/src/common/telemetry.ts @@ -0,0 +1,435 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// This code is based on https://github.com/microsoft/vscode-extension-telemetry + +import * as vscode from 'vscode'; +import { Disposable } from './dispose'; + +const enum TelemetryLevel { + ON = 'on', + ERROR = 'error', + OFF = 'off' +} + +function getTelemetryLevel(): TelemetryLevel { + const TELEMETRY_CONFIG_ID = 'telemetry'; + const TELEMETRY_CONFIG_ENABLED_ID = 'enableTelemetry'; + + if (vscode.env.isTelemetryEnabled !== undefined) { + return vscode.env.isTelemetryEnabled ? TelemetryLevel.ON : TelemetryLevel.OFF; + } + + // We use the old and new setting to determine the telemetry level as we must respect both + const config = vscode.workspace.getConfiguration(TELEMETRY_CONFIG_ID); + const enabled = config.get(TELEMETRY_CONFIG_ENABLED_ID); + return enabled ? TelemetryLevel.ON : TelemetryLevel.OFF; +} + +export interface TelemetryEventProperties { + readonly [key: string]: string; +} + +export interface RawTelemetryEventProperties { + readonly [key: string]: any; +} + +export interface AppenderData { + properties?: RawTelemetryEventProperties; +} + +export interface ITelemetryAppender { + logEvent(eventName: string, data?: AppenderData): void; + logException(exception: Error, data?: AppenderData): void; + flush(): void | Promise; + instantiateAppender(): void; +} + +export interface BaseTelemetryClient { + logEvent(eventName: string, data?: AppenderData): void; + logException(exception: Error, data?: AppenderData): void; + flush(): void | Promise; +} + +export class BaseTelemetryAppender implements ITelemetryAppender { + // Whether or not the client has been instantiated + private _isInstantiated = false; + private _telemetryClient: BaseTelemetryClient | undefined; + + // Queues used to store events until the appender is ready + private _eventQueue: Array<{ eventName: string; data: AppenderData | undefined }> = []; + private _exceptionQueue: Array<{ exception: Error; data: AppenderData | undefined }> = []; + + // Necessary information to create a telemetry client + private _clientFactory: (key: string) => Promise; + private _key: string; + + constructor(key: string, clientFactory: (key: string) => Promise) { + this._clientFactory = clientFactory; + this._key = key; + if (getTelemetryLevel() !== TelemetryLevel.OFF) { + this.instantiateAppender(); + } + } + + /** + * Sends the event to the passed in telemetry client + * @param eventName The named of the event to log + * @param data The data contanied in the event + */ + logEvent(eventName: string, data?: AppenderData): void { + if (!this._telemetryClient) { + if (!this._isInstantiated && getTelemetryLevel() === TelemetryLevel.ON) { + this._eventQueue.push({ eventName, data }); + } + return; + } + this._telemetryClient.logEvent(eventName, data); + } + + /** + * Sends an exception to the passed in telemetry client + * @param exception The exception to collect + * @param data Data associated with the exception + */ + logException(exception: Error, data?: AppenderData): void { + if (!this._telemetryClient) { + if (!this._isInstantiated && getTelemetryLevel() !== TelemetryLevel.OFF) { + this._exceptionQueue.push({ exception, data }); + } + return; + } + this._telemetryClient.logException(exception, data); + } + + /** + * Flushes the buffered telemetry data + */ + async flush(): Promise { + if (this._telemetryClient) { + await this._telemetryClient.flush(); + this._telemetryClient = undefined; + } + } + + /** + * Flushes the queued events that existed before the client was instantiated + */ + private _flushQueues(): void { + this._eventQueue.forEach(({ eventName, data }) => this.logEvent(eventName, data)); + this._eventQueue = []; + this._exceptionQueue.forEach(({ exception, data }) => this.logException(exception, data)); + this._exceptionQueue = []; + } + + /** + * Instantiates the telemetry client to make the appender "active" + */ + instantiateAppender(): void { + if (this._isInstantiated) { + return; + } + // Call the client factory to get the client and then let it know it's instatntiated + this._clientFactory(this._key).then(client => { + this._telemetryClient = client; + this._isInstantiated = true; + this._flushQueues(); + }).catch(err => { + console.error(err); + }); + } +} + +export class BaseTelemetryReporter extends Disposable { + private userOptIn = false; + private errorOptIn = false; + private _extension: vscode.Extension | undefined; + + constructor( + private extensionId: string, + private extensionVersion: string, + private telemetryAppender: ITelemetryAppender, + private osShim: { release: string; platform: string; architecture: string }, + ) { + super(); + + this.updateUserOptStatus(); + + if (vscode.env.onDidChangeTelemetryEnabled !== undefined) { + this._register(vscode.env.onDidChangeTelemetryEnabled(() => this.updateUserOptStatus())); + this._register(vscode.workspace.onDidChangeConfiguration(() => this.updateUserOptStatus())); + } else { + this._register(vscode.workspace.onDidChangeConfiguration(() => this.updateUserOptStatus())); + } + } + + /** + * Updates whether the user has opted in to having telemetry collected + */ + private updateUserOptStatus(): void { + const telemetryLevel = getTelemetryLevel(); + this.userOptIn = telemetryLevel === TelemetryLevel.ON; + this.errorOptIn = telemetryLevel === TelemetryLevel.ERROR || this.userOptIn; + if (this.userOptIn || this.errorOptIn) { + this.telemetryAppender.instantiateAppender(); + } + } + + /** + * Retrieves the current extension based on the extension id + */ + private get extension(): vscode.Extension | undefined { + if (this._extension === undefined) { + this._extension = vscode.extensions.getExtension(this.extensionId); + } + + return this._extension; + } + + /** + * Given an object and a callback creates a clone of the object and modifies it according to the callback + * @param obj The object to clone and modify + * @param change The modifying function + * @returns A new changed object + */ + private cloneAndChange(obj?: { [key: string]: string }, change?: (key: string, val: string) => string): { [key: string]: string } | undefined { + if (obj === null || typeof obj !== 'object') { return obj; } + if (typeof change !== 'function') { return obj; } + + const ret: { [key: string]: string } = {}; + for (const key in obj) { + ret[key] = change(key, obj[key]!); + } + + return ret; + } + + /** + * Whether or not it is safe to send error telemetry + */ + private shouldSendErrorTelemetry(): boolean { + if (this.errorOptIn === false) { + return false; + } + + return true; + } + + // __GDPR__COMMON__ "common.os" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.nodeArch" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.platformversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.extname" : { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.extversion" : { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.vscodemachineid" : { "endPoint": "MacAddressHash", "classification": "EndUserPseudonymizedInformation", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.vscodesessionid" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.vscodeversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.uikind" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + // __GDPR__COMMON__ "common.product" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + private getCommonProperties(): TelemetryEventProperties { + const commonProperties = Object.create(null); + commonProperties['common.os'] = this.osShim.platform; + commonProperties['common.nodeArch'] = this.osShim.architecture; + commonProperties['common.platformversion'] = (this.osShim.release || '').replace(/^(\d+)(\.\d+)?(\.\d+)?(.*)/, '$1$2$3'); + commonProperties['common.extname'] = this.extensionId; + commonProperties['common.extversion'] = this.extensionVersion; + if (vscode && vscode.env) { + commonProperties['common.vscodemachineid'] = vscode.env.machineId; + commonProperties['common.vscodesessionid'] = vscode.env.sessionId; + commonProperties['common.vscodeversion'] = vscode.version; + commonProperties['common.product'] = vscode.env.appHost; + + switch (vscode.env.uiKind) { + case vscode.UIKind.Web: + commonProperties['common.uikind'] = 'web'; + break; + case vscode.UIKind.Desktop: + commonProperties['common.uikind'] = 'desktop'; + break; + default: + commonProperties['common.uikind'] = 'unknown'; + } + } + return commonProperties; + } + + /** + * Given an error stack cleans up the file paths within + * @param stack The stack to clean + * @param anonymizeFilePaths Whether or not to clean the file paths or anonymize them as well + * @returns The cleaned stack + */ + private anonymizeFilePaths(stack?: string, anonymizeFilePaths?: boolean): string { + let result: RegExpExecArray | null | undefined; + if (stack === undefined || stack === null) { + return ''; + } + + const cleanupPatterns = []; + if (vscode.env.appRoot !== '') { + cleanupPatterns.push(new RegExp(vscode.env.appRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')); + } + if (this.extension) { + cleanupPatterns.push(new RegExp(this.extension.extensionPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')); + } + + let updatedStack = stack; + + if (anonymizeFilePaths) { + const cleanUpIndexes: [number, number][] = []; + for (const regexp of cleanupPatterns) { + while ((result = regexp.exec(stack))) { + if (!result) { + break; + } + cleanUpIndexes.push([result.index, regexp.lastIndex]); + } + } + + const nodeModulesRegex = /^[\\/]?(node_modules|node_modules\.asar)[\\/]/; + const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w-._]+(\\\\|\\|\/))+[\w-._]*/g; + let lastIndex = 0; + updatedStack = ''; + + while ((result = fileRegex.exec(stack))) { + if (!result) { + break; + } + // Anoynimize user file paths that do not need to be retained or cleaned up. + if (result[0] && !nodeModulesRegex.test(result[0]) && cleanUpIndexes.every(([x, y]) => result!.index < x || result!.index >= y)) { + updatedStack += stack.substring(lastIndex, result.index) + ''; + lastIndex = fileRegex.lastIndex; + } + } + if (lastIndex < stack.length) { + updatedStack += stack.substr(lastIndex); + } + } + + // sanitize with configured cleanup patterns + for (const regexp of cleanupPatterns) { + updatedStack = updatedStack.replace(regexp, ''); + } + return updatedStack; + } + + private removePropertiesWithPossibleUserInfo(properties: TelemetryEventProperties | undefined): TelemetryEventProperties | undefined { + if (typeof properties !== 'object') { + return; + } + const cleanedObject = Object.create(null); + // Loop through key and values of the properties object + for (const key of Object.keys(properties)) { + const value = properties[key]; + // If for some reason it is undefined we skip it (this shouldn't be possible); + if (!value) { + continue; + } + + // Regex which matches @*.site + const emailRegex = /@[a-zA-Z0-9-.]+/; + const secretRegex = /(key|token|sig|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]/; + // last +? is lazy as a microoptimization since we don't care about the full value + const tokenRegex = /xox[pbaors]-[a-zA-Z0-9]+-[a-zA-Z0-9-]+?/; + + // Check for common user data in the telemetry events + if (secretRegex.test(value.toLowerCase())) { + cleanedObject[key] = ''; + } else if (emailRegex.test(value)) { + cleanedObject[key] = ''; + } else if (tokenRegex.test(value)) { + cleanedObject[key] = ''; + } else { + cleanedObject[key] = value; + } + } + return cleanedObject; + } + + public get telemetryLevel(): 'all' | 'error' | 'crash' | 'off' { + const telemetryLevel = getTelemetryLevel(); + switch (telemetryLevel) { + case TelemetryLevel.ON: + return 'all'; + case TelemetryLevel.ERROR: + return 'error'; + case TelemetryLevel.OFF: + return 'off'; + } + } + + /** + * Given an event name, some properties, and measurements sends a telemetry event. + * Properties are sanitized on best-effort basis to remove sensitive data prior to sending. + * @param eventName The name of the event + * @param properties The properties to send with the event + */ + public sendTelemetryEvent(eventName: string, properties?: TelemetryEventProperties): void { + if (this.userOptIn && eventName !== '') { + properties = { ...properties, ...this.getCommonProperties() }; + const cleanProperties = this.cloneAndChange(properties, (_key: string, prop: string) => this.anonymizeFilePaths(prop, false)); + this.telemetryAppender.logEvent(`${eventName}`, { properties: this.removePropertiesWithPossibleUserInfo(cleanProperties) }); + } + } + + /** + * Given an event name, some properties, and measurements sends a raw (unsanitized) telemetry event + * @param eventName The name of the event + * @param properties The properties to send with the event + */ + public sendRawTelemetryEvent(eventName: string, properties?: RawTelemetryEventProperties): void { + if (this.userOptIn && eventName !== '') { + properties = { ...properties, ...this.getCommonProperties() }; + this.telemetryAppender.logEvent(`${eventName}`, { properties }); + } + } + + /** + * Given an event name, some properties, and measurements sends an error event + * @param eventName The name of the event + * @param properties The properties to send with the event + * @param errorProps If not present then we assume all properties belong to the error prop and will be anonymized + */ + public sendTelemetryErrorEvent(eventName: string, properties?: { [key: string]: string }, errorProps?: string[]): void { + if (this.errorOptIn && eventName !== '') { + // always clean the properties if first party + // do not send any error properties if we shouldn't send error telemetry + // if we have no errorProps, assume all are error props + properties = { ...properties, ...this.getCommonProperties() }; + const cleanProperties = this.cloneAndChange(properties, (key: string, prop: string) => { + if (this.shouldSendErrorTelemetry()) { + return this.anonymizeFilePaths(prop, false); + } + + if (errorProps === undefined || errorProps.indexOf(key) !== -1) { + return 'REDACTED'; + } + + return this.anonymizeFilePaths(prop, false); + }); + this.telemetryAppender.logEvent(`${eventName}`, { properties: this.removePropertiesWithPossibleUserInfo(cleanProperties) }); + } + } + + /** + * Given an error, properties, and measurements. Sends an exception event + * @param error The error to send + * @param properties The properties to send with the event + */ + public sendTelemetryException(error: Error, properties?: TelemetryEventProperties): void { + if (this.shouldSendErrorTelemetry() && this.errorOptIn && error) { + properties = { ...properties, ...this.getCommonProperties() }; + const cleanProperties = this.cloneAndChange(properties, (_key: string, prop: string) => this.anonymizeFilePaths(prop, false)); + // Also run the error stack through the anonymizer + if (error.stack) { + error.stack = this.anonymizeFilePaths(error.stack, false); + } + this.telemetryAppender.logException(error, { properties: this.removePropertiesWithPossibleUserInfo(cleanProperties) }); + } + } + + public override async dispose(): Promise { + await this.telemetryAppender.flush(); + super.dispose(); + } +} diff --git a/extensions/gitpod/src/common/utils.ts b/extensions/gitpod/src/common/utils.ts new file mode 100644 index 0000000000000..b4e364d3331f0 --- /dev/null +++ b/extensions/gitpod/src/common/utils.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter, Event, Disposable } from 'vscode'; + +export function filterEvent(event: Event, filter: (e: T) => boolean): Event { + return (listener, thisArgs = null, disposables?) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); +} + +export function onceEvent(event: Event): Event { + return (listener, thisArgs = null, disposables?) => { + const result = event(e => { + result.dispose(); + return listener.call(thisArgs, e); + }, null, disposables); + + return result; + }; +} + +export interface PromiseAdapter { + ( + value: T, + resolve: + (value: U | PromiseLike) => void, + reject: + (reason: any) => void + ): any; +} + +const passthrough = (value: any, resolve: (value?: any) => void) => resolve(value); + +/** + * Return a promise that resolves with the next emitted event, or with some future + * event as decided by an adapter. + * + * If specified, the adapter is a function that will be called with + * `(event, resolve, reject)`. It will be called once per event until it resolves or + * rejects. + * + * The default adapter is the passthrough function `(value, resolve) => resolve(value)`. + * + * @param event the event + * @param adapter controls resolution of the returned promise + * @returns a promise that resolves or rejects as specified by the adapter + */ +export function promiseFromEvent( + event: Event, + adapter: PromiseAdapter = passthrough): { promise: Promise; cancel: EventEmitter } { + let subscription: Disposable; + let cancel = new EventEmitter(); + return { + promise: new Promise((resolve, reject) => { + cancel.event(_ => reject('Cancelled')); + subscription = event((value: T) => { + try { + Promise.resolve(adapter(value, resolve, reject)) + .catch(reject); + } catch (error) { + reject(error); + } + }); + }).then( + (result: U) => { + subscription.dispose(); + return result; + }, + error => { + subscription.dispose(); + throw error; + } + ), + cancel + }; +} + +export function arrayEquals(one: ReadonlyArray | undefined, other: ReadonlyArray | undefined, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean { + if (one === other) { + return true; + } + + if (!one || !other) { + return false; + } + + if (one.length !== other.length) { + return false; + } + + for (let i = 0, len = one.length; i < len; i++) { + if (!itemEquals(one[i], other[i])) { + return false; + } + } + + return true; +} diff --git a/extensions/gitpod/src/extension.ts b/extensions/gitpod/src/extension.ts index f0418cd823fef..fdaddcdc947d6 100644 --- a/extensions/gitpod/src/extension.ts +++ b/extensions/gitpod/src/extension.ts @@ -2,436 +2,58 @@ * Copyright (c) Gitpod. All rights reserved. *--------------------------------------------------------------------------------------------*/ -/// - -import { AutoTunnelRequest, ResolveSSHConnectionRequest, ResolveSSHConnectionResponse } from '@gitpod/local-app-api-grpcweb/lib/localapp_pb'; -import { LocalAppClient } from '@gitpod/local-app-api-grpcweb/lib/localapp_pb_service'; -import { NodeHttpTransport } from '@improbable-eng/grpc-web-node-http-transport'; -import * as cp from 'child_process'; -import * as fs from 'fs'; -import * as http from 'http'; -import * as net from 'net'; -import fetch, { Response } from 'node-fetch'; -import * as tmp from 'tmp'; -import * as path from 'path'; import * as vscode from 'vscode'; -import { grpc } from '@improbable-eng/grpc-web'; +import Log from './common/logger'; +import GitpodAuthenticationProvider from './authentication'; +import LocalApp from './localApp'; +import { enableSettingsSync, updateSyncContext } from './settingsSync'; +import { GitpodServer } from './gitpodServer'; +import TelemetryReporter from './telemetryReporter'; import { exportLogs } from './exportLogs'; -interface SSHConnectionParams { - workspaceId: string; - instanceId: string; - gitpodHost: string; -} - -interface LocalAppConfig { - gitpodHost: string; - configFile: string; - apiPort: number; - pid: number; - logPath: string; -} +const EXTENSION_ID = 'gitpod.gitpod-desktop'; +const FIRST_INSTALL_KEY = 'gitpod-desktop.firstInstall'; +const ANALITYCS_KEY = 'bUY8IRdJ42KjLOBS9LoIHMYFBD8rSzjU'; -interface Lock { - pid?: number; - value: string; - deadline: number; -} - -interface LocalAppInstallation { - path: string; - etag: string | null; -} +let telemetry: TelemetryReporter; export async function activate(context: vscode.ExtensionContext) { - const output = vscode.window.createOutputChannel('Gitpod'); - function log(value: string) { - output.appendLine(`[${new Date().toLocaleString()}] ${value}`); - } - - // TODO(ak) commands to show logs and stop local apps - // TODO(ak) auto stop local apps if not used for 3 hours - - function throwIfCancelled(token?: vscode.CancellationToken): void { - if (token?.isCancellationRequested) { - throw new Error('cancelled'); - } - } - - const lockPrefix = 'lock/'; - const checkStaleInterval = 30000; - const installLockTimeout = 300000; - function isLock(lock: any): lock is Lock { - return !!lock && typeof lock === 'object'; - } - function releaseStaleLocks(): void { - for (const key of context.globalState.keys()) { - if (key.startsWith(lockPrefix)) { - const lock = context.globalState.get(key); - if (!isLock(lock) || Date.now() >= lock.deadline || (typeof lock.pid === 'number' && checkRunning(lock.pid) !== true)) { - const lockName = key.substr(lockPrefix.length); - log(`cancel stale lock: ${lockName}`); - context.globalState.update(key, undefined); + const logger = new Log('Gitpod'); + + const version = vscode.extensions.getExtension(EXTENSION_ID)!.packageJSON.version; + telemetry = new TelemetryReporter(EXTENSION_ID, version, ANALITYCS_KEY); + + /* Gitpod settings sync */ + await updateSyncContext(); + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration('gitpod.host') || e.affectsConfiguration('configurationSync.store')) { + const addedSyncProvider = await updateSyncContext(); + if (!addedSyncProvider) { + const action = 'Settings Sync: Enable Sign In with Gitpod'; + const result = await vscode.window.showInformationMessage('Gitpod Settings Sync configuration invalidated, Settings Sync is disabled.', action); + if (result === action) { + vscode.commands.executeCommand('gitpod.syncProvider.add'); } } } - } - let lockCount = 0; - async function withLock(lockName: string, op: (token: vscode.CancellationToken) => Promise, timeout: number, token?: vscode.CancellationToken): Promise { - log(`acquiring lock: ${lockName}`); - const lockKey = lockPrefix + lockName; - const value = vscode.env.sessionId + '/' + lockCount++; - let currentLock: Lock | undefined; - let deadline: number | undefined; - const updateTimeout = 150; - while (currentLock?.value !== value) { - currentLock = context.globalState.get(lockKey); - if (!currentLock) { - deadline = Date.now() + timeout + updateTimeout * 2; - await context.globalState.update(lockKey, { value, deadline, pid: process.pid }); - } - // TODO(ak) env.globaState.onDidChange instead, see https://github.com/microsoft/vscode/issues/131182 - await new Promise(resolve => setTimeout(resolve, updateTimeout)); - currentLock = context.globalState.get(lockKey); - } - log(`acquired lock: ${lockName}`); - const tokenSource = new vscode.CancellationTokenSource(); - token?.onCancellationRequested(() => tokenSource.cancel()); - let timer = setInterval(() => { - currentLock = context.globalState.get(lockKey); - if (currentLock?.value !== value) { - tokenSource.cancel(); - } - }, updateTimeout); - try { - const result = await op(tokenSource.token); - return result; - } finally { - if (timer) { - clearTimeout(timer); - } - log(`released lock: ${lockName}`); - await context.globalState.update(lockKey, undefined); - } - } - - releaseStaleLocks(); - const releaseStaleLocksTimer = setInterval(() => releaseStaleLocks(), checkStaleInterval); - context.subscriptions.push(new vscode.Disposable(() => clearInterval(releaseStaleLocksTimer))); + })); - function checkRunning(pid: number): true | Error { + context.subscriptions.push(vscode.commands.registerCommand('gitpod.syncProvider.remove', async () => { try { - process.kill(pid, 0); - return true; + await enableSettingsSync(false, telemetry); } catch (e) { - return e; - } - } - - function downloadLocalApp(gitpodHost: string): Promise { - let downloadUri = vscode.Uri.parse(gitpodHost); - let arch = ''; - if (process.arch === 'arm64') { - arch = '-arm64'; - } if (process.arch === 'x32' && process.platform === 'win32') { - arch = '-386'; - } - if (process.platform === 'win32') { - downloadUri = downloadUri.with({ - path: `/static/bin/gitpod-local-companion-windows${arch}.exe` - }); - } else if (process.platform === 'darwin') { - downloadUri = downloadUri.with({ - path: `/static/bin/gitpod-local-companion-darwin${arch}` - }); - } else { - downloadUri = downloadUri.with({ - path: `/static/bin/gitpod-local-companion-linux${arch}` - }); - } - log(`fetching the local app from ${downloadUri.toString()}`); - return fetch(downloadUri.toString()); - } - - async function installLocalApp(download: Response, token: vscode.CancellationToken): Promise { - try { - const fileExtension = process.platform === 'win32' ? '.exe' : undefined; - const installationPath = await new Promise((resolve, reject) => - tmp.file({ prefix: 'gitpod-local-companion', postfix: fileExtension, keep: true, discardDescriptor: true }, (err, path) => { - if (err) { - return reject(err); - } - return resolve(path); - }) - ); - throwIfCancelled(token); - log(`installing the local app to ${installationPath}`); - const installationStream = fs.createWriteStream(installationPath); - const cancelInstallationListener = token.onCancellationRequested(() => installationStream.destroy(new Error('cancelled'))); - await new Promise((resolve, reject) => { - download.body.pipe(installationStream) - .on('error', reject) - .on('finish', resolve); - }).finally(() => { - cancelInstallationListener.dispose(); - installationStream.destroy(); - }); - - throwIfCancelled(token); - if (process.platform !== 'win32') { - await fs.promises.chmod(installationPath, '755'); - throwIfCancelled(token); - } - const installation: LocalAppInstallation = { path: installationPath, etag: download.headers.get('etag') }; - log(`installing the local app: ${JSON.stringify(installation, undefined, 2)}`); - return installation; - } catch (e) { - log(`failed to install the local app: ${e}`); - throw e; + const outputMessage = `Error setting up Settings Sync with Gitpod: ${e}`; + vscode.window.showErrorMessage(outputMessage); + logger.error(outputMessage); } - } - - async function startLocalApp(gitpodHost: string, installation: LocalAppInstallation, token: vscode.CancellationToken): Promise { + })); + context.subscriptions.push(vscode.commands.registerCommand('gitpod.syncProvider.add', async () => { try { - const [configFile, apiPort] = await Promise.all([new Promise((resolve, reject) => - tmp.file({ prefix: 'gitpod_ssh_config', keep: true, discardDescriptor: true }, (err, path) => { - if (err) { - return reject(err); - } - return resolve(path); - }) - ), new Promise(resolve => { - const server = http.createServer(); - server.listen(0, 'localhost', () => { - resolve((server.address() as net.AddressInfo).port); - server.close(); - }); - })]); - throwIfCancelled(token); - log(`starting the local app with the config: ${JSON.stringify({ gitpodHost, configFile: vscode.Uri.file(configFile).toString(), apiPort }, undefined, 2)}`); - - const parsed = path.parse(installation.path); - const logPath = path.join(parsed.dir, parsed.name) + '.log'; - const logStream = fs.createWriteStream(logPath); - const cancelLogStreamListener = token.onCancellationRequested(() => logStream.destroy(new Error('cancelled'))); - await new Promise((resolve, reject) => { - logStream.on('error', reject); - logStream.on('open', resolve); - }).finally(() => { - cancelLogStreamListener.dispose(); - }); - - const localAppProcess = cp.spawn(installation.path, { - detached: true, - stdio: ['ignore', logStream, logStream], - env: { - ...process.env, - GITPOD_HOST: gitpodHost, - GITPOD_LCA_SSH_CONFIG: configFile, - GITPOD_LCA_API_PORT: String(apiPort), - GITPOD_LCA_AUTO_TUNNEL: String(false), - GITPOD_LCA_AUTH_REDIRECT_URL: `${vscode.env.uriScheme}://${context.extension.id}${authCompletePath}`, - GITPOD_LCA_VERBOSE: String(vscode.workspace.getConfiguration('gitpod').get('verbose', false)), - GITPOD_LCA_TIMEOUT: String(vscode.workspace.getConfiguration('gitpod').get('timeout', '3h')) - } - }); - localAppProcess.unref(); - const cancelLocalAppProcessListener = token.onCancellationRequested(() => localAppProcess.kill()); - const pid = await new Promise((resolve, reject) => { - localAppProcess.on('error', reject); - localAppProcess.on('exit', code => reject(new Error('unexpectedly exit with code: ' + code))); - localAppProcess.on('spawn', () => resolve(localAppProcess.pid!)); - }).finally(() => { - cancelLocalAppProcessListener.dispose(); - }); - - log(`the local app has been stared: ${JSON.stringify({ pid, log: vscode.Uri.file(logPath).toString() }, undefined, 2)}`); - return { gitpodHost, configFile, apiPort, pid, logPath }; + await enableSettingsSync(true, telemetry); } catch (e) { - log(`failed to start the local app: ${e}`); - throw e; - } - } - - /** - * **Important: it should not call the local app to manage in 30sec** - */ - async function ensureLocalApp(gitpodHost: string, configKey: string, installationKey: string, token: vscode.CancellationToken): Promise { - let config = context.globalState.get(configKey); - let installation = context.globalState.get(installationKey); - - if (config && checkRunning(config?.pid) !== true) { - config = undefined; - } - - const gitpodConfig = vscode.workspace.getConfiguration('gitpod'); - const configuredInstallationPath = gitpodConfig.get('installationPath'); - if (configuredInstallationPath) { - if (installation && installation.path !== configuredInstallationPath) { - log(`the local app is different from configured, switching: ${JSON.stringify({ installed: installation.path, configured: configuredInstallationPath }, undefined, 2)}`); - installation = undefined; - if (config) { - try { - process.kill(config.pid); - } catch (e) { - log(`failed to kill the outdated local app (pid: ${config.pid}): ${e}`); - } - } - config = undefined; - } - if (config) { - return config; - } - await fs.promises.access(configuredInstallationPath, fs.constants.X_OK); - throwIfCancelled(token); - installation = { path: configuredInstallationPath, etag: null }; - await context.globalState.update(installationKey, installation); - throwIfCancelled(token); - } else { - let download: Response | Error; - try { - download = await downloadLocalApp(gitpodHost); - throwIfCancelled(token); - if (!download.ok) { - download = new Error(`unexpected download response ${download.statusText} (${download.status})`); - } - } catch (e) { - download = e; - } - if (installation) { - const upgrade = !(download instanceof Error) && { etag: download.headers.get('etag'), url: download.url }; - if (upgrade && upgrade.etag && upgrade.etag !== installation.etag) { - log(`the local app is outdated, upgrading: ${JSON.stringify({ installation, upgrade }, undefined, 2)}`); - installation = undefined; - if (config) { - try { - process.kill(config.pid); - } catch (e) { - log(`failed to kill the outdated local app (pid: ${config.pid}): ${e}`); - } - } - config = undefined; - } - } - if (config) { - return config; - } - if (installation) { - try { - await fs.promises.access(installation.path, fs.constants.X_OK); - } catch { - installation = undefined; - } - throwIfCancelled(token); - } - if (!installation) { - if (download instanceof Error) { - throw download; - } - installation = await installLocalApp(download, token); - await context.globalState.update(installationKey, installation); - throwIfCancelled(token); - } - } - config = await startLocalApp(gitpodHost, installation, token); - await context.globalState.update(configKey, config); - throwIfCancelled(token); - return config; - } - - async function withLocalApp(gitpodHost: string, op: (client: LocalAppClient, config: LocalAppConfig) => Promise, token?: vscode.CancellationToken): Promise { - const gitpodAuthority = vscode.Uri.parse(gitpodHost).authority; - const configKey = 'config/' + gitpodAuthority; - const installationKey = 'installation/' + gitpodAuthority; - const config = await withLock(gitpodAuthority, token => - ensureLocalApp(gitpodHost, configKey, installationKey, token) - , installLockTimeout, token); - throwIfCancelled(token); - while (true) { - const client = new LocalAppClient('http://localhost:' + config.apiPort, { transport: NodeHttpTransport() }); - try { - const result = await op(client, config); - throwIfCancelled(token); - return result; - } catch (e) { - throwIfCancelled(token); - const running = checkRunning(config.pid); - if (running === true && (e.code === grpc.Code.Unavailable || e.code === grpc.Code.Unknown)) { - log(`the local app (pid: ${config.pid}) is running, but the api endpoint is not ready: ${e}`); - log(`retying again after 1s delay...`); - await new Promise(resolve => setTimeout(resolve, 1000)); - throwIfCancelled(token); - continue; - } - if (running !== true) { - log(`the local app (pid: ${config.pid}) is not running: ${running}`); - } - log(`failed to access the local app: ${e}`); - throw e; - } - } - } - - const authCompletePath = '/auth-complete'; - context.subscriptions.push(vscode.window.registerUriHandler({ - handleUri: async uri => { - if (uri.path === authCompletePath) { - log('auth completed'); - return; - } - log('open workspace window: ' + uri.toString()); - const params: SSHConnectionParams = JSON.parse(uri.query); - let resolvedConfig: LocalAppConfig | undefined; - try { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - cancellable: true, - title: `Connecting to Gitpod workspace: ${params.workspaceId}` - }, async (_, token) => { - const connection = await withLocalApp(params.gitpodHost, (client, config) => { - resolvedConfig = config; - const request = new ResolveSSHConnectionRequest(); - request.setInstanceId(params.instanceId); - request.setWorkspaceId(params.workspaceId); - return new Promise((resolve, reject) => - client.resolveSSHConnection(request, (e, r) => r ? resolve(r) : reject(e)) - ); - }, token); - - const config = vscode.workspace.getConfiguration('remote.SSH'); - const defaultExtensions = config.get('defaultExtensions') || []; - if (defaultExtensions.indexOf('gitpod.gitpod-remote-ssh') === -1) { - defaultExtensions.unshift('gitpod.gitpod-remote-ssh'); - await config.update('defaultExtensions', defaultExtensions, vscode.ConfigurationTarget.Global); - } - // TODO(ak) notify a user about config file changes? - const gitpodConfigFile = connection.getConfigFile(); - const currentConfigFile = config.get('configFile'); - if (currentConfigFile === gitpodConfigFile) { - // invalidate cached SSH targets from the current config file - await config.update('configFile', undefined, vscode.ConfigurationTarget.Global); - } - await config.update('configFile', gitpodConfigFile, vscode.ConfigurationTarget.Global); - // TODO(ak) ensure that vscode.ssh-remote is installed - await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.parse(`vscode-remote://ssh-remote+${connection.getHost()}${uri.path || '/'}`), { - forceNewWindow: true - }); - }); - } catch (e) { - const seeLogs = 'See Logs'; - vscode.window.showErrorMessage(`Failed to connect to Gitpod workspace ${params.workspaceId}: ${e}`, seeLogs).then(async result => { - if (result !== seeLogs) { - return; - } - output.show(); - if (resolvedConfig) { - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(resolvedConfig.logPath)); - vscode.window.showTextDocument(document); - } - }); - log(`failed to open uri: ${e}`); - throw e; - } + const outputMessage = `Error setting up Settings Sync with Gitpod: ${e}`; + vscode.window.showErrorMessage(outputMessage); + logger.error(outputMessage); } })); @@ -441,29 +63,33 @@ export async function activate(context: vscode.ExtensionContext) { } catch (e) { const outputMessage = `Error exporting logs: ${e}`; vscode.window.showErrorMessage(outputMessage); - log(outputMessage); - // logger.error(outputMessage); + logger.error(outputMessage); } })); - if (vscode.env.remoteName === undefined || context.extension.extensionKind !== vscode.ExtensionKind.UI) { - return; - } - - context.subscriptions.push(vscode.commands.registerCommand('gitpod.api.autoTunnel', async (gitpodHost: string, instanceId: string, enabled: boolean) => { - try { - await withLocalApp(gitpodHost, client => { - const request = new AutoTunnelRequest(); - request.setInstanceId(instanceId); - request.setEnabled(enabled); - return new Promise((resolve, reject) => - client.autoTunnel(request, (e, r) => r ? resolve(undefined) : reject(e)) - ); - }); - } catch (e) { - console.error('failed to disable auto tunneling', e); + const authProvider = new GitpodAuthenticationProvider(context, logger, telemetry); + const localApp = new LocalApp(context, logger); + context.subscriptions.push(authProvider); + context.subscriptions.push(localApp); + context.subscriptions.push(vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri) { + // logger.trace('Handling Uri...', uri.toString()); + if (uri.path === GitpodServer.AUTH_COMPLETE_PATH) { + authProvider.handleUri(uri); + } else { + localApp.handleUri(uri); + } } })); + + if (!context.globalState.get(FIRST_INSTALL_KEY, false)) { + context.globalState.update(FIRST_INSTALL_KEY, true); + telemetry.sendTelemetryEvent('gitpod_desktop_installation', { kind: 'install' }); + } } -export function deactivate() { } +export async function deactivate() { + if (telemetry) { + await telemetry.dispose(); + } +} diff --git a/extensions/gitpod/src/gitpodServer.ts b/extensions/gitpod/src/gitpodServer.ts new file mode 100644 index 0000000000000..23749a1739064 --- /dev/null +++ b/extensions/gitpod/src/gitpodServer.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import Log from './common/logger'; +import fetch from 'node-fetch'; +import { PromiseAdapter, promiseFromEvent } from './common/utils'; +import { withServerApi } from './internalApi'; +import pkceChallenge from 'pkce-challenge'; +import { v4 as uuid } from 'uuid'; +import { Disposable } from './common/dispose'; + +interface ExchangeTokenResponse { + token_type: 'Bearer'; + expires_in: number; + access_token: string; + refresh_token: string; + scope: string; +} + +async function getUserInfo(token: string, serviceUrl: string, logger: Log) { + const user = await withServerApi(token, serviceUrl, service => service.server.getLoggedInUser(), logger); + return { + id: user.id, + accountName: user.name ?? user.fullName ?? '' + }; +} + +export class GitpodServer extends Disposable { + + public static AUTH_COMPLETE_PATH = '/complete-gitpod-auth'; + + private _serviceUrl: string; + private _pendingStates = new Map(); + private _pendingVerifiers = new Map(); + private _codeExchangePromises = new Map; cancel: vscode.EventEmitter }>(); + private _uriEmitter = this._register(new vscode.EventEmitter()); + + constructor(serviceUrl: string, private readonly _logger: Log) { + super(); + + this._serviceUrl = serviceUrl.replace(/\/$/, ''); + } + + public async login(scopes: string): Promise { + this._logger.info(`Logging in for the following scopes: ${scopes}`); + + const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://gitpod.gitpod-desktop/complete-gitpod-auth`)); + + const { code_verifier, code_challenge } = pkceChallenge(128); + const state = uuid(); + const searchParams = new URLSearchParams([ + ['response_type', 'code'], + ['client_id', `${vscode.env.uriScheme}-gitpod`], + ['redirect_uri', callbackUri.toString(true)], + ['scope', scopes], + ['state', state], + ['code_challenge', code_challenge], + ['code_challenge_method', 'S256'] + ]); + + const uri = `${this._serviceUrl}/api/oauth/authorize?${searchParams.toString()}`; + + const existingStates = this._pendingStates.get(scopes) || []; + this._pendingStates.set(scopes, [...existingStates, state]); + this._pendingVerifiers.set(state, code_verifier); + + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Window, + title: `Signing in to ${this._serviceUrl}...`, + }, async () => { + await vscode.env.openExternal(uri as any); + // this._logger.trace(">> URL ", uri); + + // Register a single listener for the URI callback, in case the user starts the login process multiple times + // before completing it. + let codeExchangePromise = this._codeExchangePromises.get(scopes); + if (!codeExchangePromise) { + codeExchangePromise = promiseFromEvent(this._uriEmitter.event, this.exchangeCodeForToken(scopes)); + this._codeExchangePromises.set(scopes, codeExchangePromise); + } + + return Promise.race([ + codeExchangePromise.promise, + new Promise((_, reject) => setTimeout(() => reject('Cancelled'), 60000)) + ]).finally(() => { + const states = this._pendingStates.get(scopes); + if (states) { + states.forEach(state => this._pendingVerifiers.delete(state)); + } + this._pendingStates.delete(scopes); + codeExchangePromise?.cancel.fire(); + this._codeExchangePromises.delete(scopes); + }); + }); + } + + private exchangeCodeForToken: (scopes: string) => PromiseAdapter = + (scopes) => async (uri, resolve, reject) => { + const query = new URLSearchParams(uri.query); + const code = query.get('code'); + const state = query.get('state'); + + if (!code) { + this._logger.error('No code in response.'); + return; + } + + if (!state) { + this._logger.error('No state in response.'); + return; + } + + const acceptedStates = this._pendingStates.get(scopes) || []; + if (!acceptedStates.includes(state)) { + // A common scenario of this happening is if you: + // 1. Trigger a sign in with one set of scopes + // 2. Before finishing 1, you trigger a sign in with a different set of scopes + // In this scenario we should just return and wait for the next UriHandler event + // to run as we are probably still waiting on the user to hit 'Continue' + this._logger.info('Nonce not found in accepted nonces. Skipping this execution...'); + return; + } + + const verifier = this._pendingVerifiers.get(state); + if (!verifier) { + this._logger.error('Code verifier not found in memory.'); + return; + } + + this._logger.info('Exchanging code for token...'); + + const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://gitpod.gitpod-desktop${GitpodServer.AUTH_COMPLETE_PATH}`)); + try { + const exchangeTokenResponse = await fetch(`${this._serviceUrl}/api/oauth/token`, { + method: 'POST', + body: new URLSearchParams({ + code, + grant_type: 'authorization_code', + client_id: `${vscode.env.uriScheme}-gitpod`, + redirect_uri: callbackUri.toString(true), + code_verifier: verifier + }) + }); + + if (!exchangeTokenResponse.ok) { + vscode.window.showErrorMessage(`Couldn't connect (token exchange): ${exchangeTokenResponse.statusText}, ${await exchangeTokenResponse.text()}`); + reject(exchangeTokenResponse.statusText); + return; + } + + const exchangeTokenData: ExchangeTokenResponse = await exchangeTokenResponse.json(); + const jwtToken = exchangeTokenData.access_token; + const accessToken = JSON.parse(Buffer.from(jwtToken.split('.')[1], 'base64').toString())['jti']; + resolve(accessToken); + } catch (err) { + reject(err); + } + }; + + public getUserInfo(token: string): Promise<{ id: string; accountName: string }> { + return getUserInfo(token, this._serviceUrl, this._logger); + } + + public hadleUri(uri: vscode.Uri) { + this._uriEmitter.fire(uri); + } + + public override dispose() { + super.dispose(); + for (const [, { cancel }] of this._codeExchangePromises) { + cancel.fire(); + } + } +} diff --git a/extensions/gitpod/src/internalApi.ts b/extensions/gitpod/src/internalApi.ts new file mode 100644 index 0000000000000..e1e07594d5322 --- /dev/null +++ b/extensions/gitpod/src/internalApi.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { GitpodClient, GitpodServer, GitpodServiceImpl } from '@gitpod/gitpod-protocol/lib/gitpod-service'; +import { JsonRpcProxyFactory } from '@gitpod/gitpod-protocol/lib/messaging/proxy-factory'; +import { listen as doListen } from 'vscode-ws-jsonrpc'; +import WebSocket from 'ws'; +import ReconnectingWebSocket from 'reconnecting-websocket'; +import * as vscode from 'vscode'; +import Log from './common/logger'; + +type UsedGitpodFunction = ['getLoggedInUser', 'getGitpodTokenScopes']; +type Union = Tuple[number] | Union; +export type GitpodConnection = Omit, 'server'> & { + server: Pick>; +}; + +export const unauthorizedErr = 'unauthorized'; + +class GitpodServerApi extends vscode.Disposable { + + readonly service: GitpodConnection; + private readonly webSocket: any; + private readonly onWillCloseEmitter = new vscode.EventEmitter(); + readonly onWillClose = this.onWillCloseEmitter.event; + + constructor(accessToken: string, serviceUrl: string, private readonly logger: Log) { + super(() => this.internalDispose()); + + const factory = new JsonRpcProxyFactory(); + this.service = new GitpodServiceImpl(factory.createProxy()); + + let retry = 1; + const maxRetries = 3; + const webSocket = new ReconnectingWebSocket(`${serviceUrl.replace('https', 'wss')}/api/v1`, undefined, { + maxReconnectionDelay: 10000, + minReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + connectionTimeout: 10000, + maxRetries: Infinity, + debug: false, + startClosed: false, + WebSocket: class extends WebSocket { + constructor(address: string, protocols?: string | string[]) { + super(address, protocols, { + headers: { + 'Origin': new URL(serviceUrl).origin, + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': vscode.env.appName, + 'X-Client-Version': vscode.version + } + }); + this.on('unexpected-response', (_, resp) => { + this.terminate(); + + // if mal-formed handshake request (unauthorized, forbidden) or client actions (redirect) are required then fail immediately + // otherwise try several times and fail, maybe temporarily unavailable, like server restart + if (retry++ >= maxRetries || (typeof resp.statusCode === 'number' && 300 <= resp.statusCode && resp.statusCode < 500)) { + webSocket.close(resp.statusCode); + } + }); + } + } + }); + webSocket.onerror = (e: any) => logger.error('internal server api: failed to open socket', e); + + doListen({ + webSocket, + logger: this.logger, + onConnection: connection => factory.listen(connection), + }); + this.webSocket = webSocket; + } + + private close(statusCode?: number): void { + this.onWillCloseEmitter.fire(statusCode); + try { + this.webSocket.close(); + } catch (e) { + this.logger.error('internal server api: failed to close socket', e); + } + } + + internalDispose() { + this.close(); + this.onWillCloseEmitter.dispose(); + } +} + +export function withServerApi(accessToken: string, serviceUrl: string, cb: (service: GitpodConnection) => Promise, logger: Log): Promise { + const api = new GitpodServerApi(accessToken, serviceUrl, logger); + return Promise.race([ + cb(api.service), + new Promise((_, reject) => api.onWillClose(statusCode => { + if (statusCode === 401) { + reject(new Error(unauthorizedErr)); + } else { + reject(new Error('closed')); + } + })) + ]).finally(() => api.dispose()); +} diff --git a/extensions/gitpod/src/localApp.ts b/extensions/gitpod/src/localApp.ts new file mode 100644 index 0000000000000..31e956f351801 --- /dev/null +++ b/extensions/gitpod/src/localApp.ts @@ -0,0 +1,456 @@ +import { AutoTunnelRequest, ResolveSSHConnectionRequest, ResolveSSHConnectionResponse } from '@gitpod/local-app-api-grpcweb/lib/localapp_pb'; +import { LocalAppClient } from '@gitpod/local-app-api-grpcweb/lib/localapp_pb_service'; +import { NodeHttpTransport } from '@improbable-eng/grpc-web-node-http-transport'; +import { grpc } from '@improbable-eng/grpc-web'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; +import fetch, { Response } from 'node-fetch'; +import * as tmp from 'tmp'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { Disposable } from './common/dispose'; + +interface SSHConnectionParams { + workspaceId: string; + instanceId: string; + gitpodHost: string; +} + +interface LocalAppConfig { + gitpodHost: string; + configFile: string; + apiPort: number; + pid: number; + logPath: string; +} + +interface Lock { + pid?: number; + value: string; + deadline: number; +} + +interface LocalAppInstallation { + path: string; + etag: string | null; +} + +// TODO(ak) commands to show logs and stop local apps +// TODO(ak) auto stop local apps if not used for 3 hours + +function throwIfCancelled(token?: vscode.CancellationToken): void { + if (token?.isCancellationRequested) { + throw new Error('cancelled'); + } +} + +const lockPrefix = 'lock/'; +const checkStaleInterval = 30000; +const installLockTimeout = 300000; +function isLock(lock: any): lock is Lock { + return !!lock && typeof lock === 'object'; +} + +function checkRunning(pid: number): true | Error { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e; + } +} + +export default class LocalApp extends Disposable { + + public static AUTH_COMPLETE_PATH = '/auth-complete'; + private static lockCount = 0; + + constructor(private readonly context: vscode.ExtensionContext, private readonly logger: Log) { + super(); + + this.releaseStaleLocks(); + + if (vscode.env.remoteName && context.extension.extensionKind === vscode.ExtensionKind.UI) { + this._register(vscode.commands.registerCommand('gitpod.api.autoTunnel', this.autoTunnelCommand.bind(this))); + } + } + + private releaseStaleLocks(): void { + const releaseLocks = () => { + for (const key of this.context.globalState.keys()) { + if (key.startsWith(lockPrefix)) { + const lock = this.context.globalState.get(key); + if (!isLock(lock) || Date.now() >= lock.deadline || (typeof lock.pid === 'number' && checkRunning(lock.pid) !== true)) { + const lockName = key.slice(0, lockPrefix.length); + this.logger.info(`cancel stale lock: ${lockName}`); + this.context.globalState.update(key, undefined); + } + } + } + }; + + releaseLocks(); + const releaseStaleLocksTimer = setInterval(releaseLocks, checkStaleInterval); + this._register(new vscode.Disposable(() => clearInterval(releaseStaleLocksTimer))); + } + + private async withLock(lockName: string, op: (token: vscode.CancellationToken) => Promise, timeout: number, token?: vscode.CancellationToken): Promise { + this.logger.info(`acquiring lock: ${lockName}`); + const lockKey = lockPrefix + lockName; + const value = vscode.env.sessionId + '/' + LocalApp.lockCount++; + let currentLock: Lock | undefined; + let deadline: number | undefined; + const updateTimeout = 150; + while (currentLock?.value !== value) { + currentLock = this.context.globalState.get(lockKey); + if (!currentLock) { + deadline = Date.now() + timeout + updateTimeout * 2; + await this.context.globalState.update(lockKey, { value, deadline, pid: process.pid }); + } + // TODO(ak) env.globaState.onDidChange instead, see https://github.com/microsoft/vscode/issues/131182 + await new Promise(resolve => setTimeout(resolve, updateTimeout)); + currentLock = this.context.globalState.get(lockKey); + } + this.logger.info(`acquired lock: ${lockName}`); + const tokenSource = new vscode.CancellationTokenSource(); + token?.onCancellationRequested(() => tokenSource.cancel()); + let timer = setInterval(() => { + currentLock = this.context.globalState.get(lockKey); + if (currentLock?.value !== value) { + tokenSource.cancel(); + } + }, updateTimeout); + try { + const result = await op(tokenSource.token); + return result; + } finally { + if (timer) { + clearTimeout(timer); + } + this.logger.info(`released lock: ${lockName}`); + await this.context.globalState.update(lockKey, undefined); + } + } + + private downloadLocalApp(gitpodHost: string): Promise { + let downloadUri = vscode.Uri.parse(gitpodHost); + let arch = ''; + if (process.arch === 'arm64') { + arch = '-arm64'; + } if (process.arch === 'x32' && process.platform === 'win32') { + arch = '-386'; + } + if (process.platform === 'win32') { + downloadUri = downloadUri.with({ + path: `/static/bin/gitpod-local-companion-windows${arch}.exe` + }); + } else if (process.platform === 'darwin') { + downloadUri = downloadUri.with({ + path: `/static/bin/gitpod-local-companion-darwin${arch}` + }); + } else { + downloadUri = downloadUri.with({ + path: `/static/bin/gitpod-local-companion-linux${arch}` + }); + } + this.logger.info(`fetching the local app from ${downloadUri.toString()}`); + return fetch(downloadUri.toString()); + } + + private async installLocalApp(download: Response, token: vscode.CancellationToken): Promise { + try { + const fileExtension = process.platform === 'win32' ? '.exe' : undefined; + const installationPath = await new Promise((resolve, reject) => + tmp.file({ prefix: 'gitpod-local-companion', postfix: fileExtension, keep: true, discardDescriptor: true }, (err, path) => { + if (err) { + return reject(err); + } + return resolve(path); + }) + ); + throwIfCancelled(token); + this.logger.info(`installing the local app to ${installationPath}`); + const installationStream = fs.createWriteStream(installationPath); + const cancelInstallationListener = token.onCancellationRequested(() => installationStream.destroy(new Error('cancelled'))); + await new Promise((resolve, reject) => { + download.body.pipe(installationStream) + .on('error', reject) + .on('finish', resolve); + }).finally(() => { + cancelInstallationListener.dispose(); + installationStream.destroy(); + }); + + throwIfCancelled(token); + if (process.platform !== 'win32') { + await fs.promises.chmod(installationPath, '755'); + throwIfCancelled(token); + } + const installation: LocalAppInstallation = { path: installationPath, etag: download.headers.get('etag') }; + this.logger.info(`installing the local app: ${JSON.stringify(installation, undefined, 2)}`); + return installation; + } catch (e) { + this.logger.error(`failed to install the local app: ${e}`); + throw e; + } + } + + private async startLocalApp(gitpodHost: string, installation: LocalAppInstallation, token: vscode.CancellationToken): Promise { + try { + const [configFile, apiPort] = await Promise.all([new Promise((resolve, reject) => + tmp.file({ prefix: 'gitpod_ssh_config', keep: true, discardDescriptor: true }, (err, path) => { + if (err) { + return reject(err); + } + return resolve(path); + }) + ), new Promise(resolve => { + const server = http.createServer(); + server.listen(0, 'localhost', () => { + resolve((server.address() as net.AddressInfo).port); + server.close(); + }); + })]); + throwIfCancelled(token); + this.logger.info(`starting the local app with the config: ${JSON.stringify({ gitpodHost, configFile: vscode.Uri.file(configFile).toString(), apiPort }, undefined, 2)}`); + + const parsed = path.parse(installation.path); + const logPath = path.join(parsed.dir, parsed.name) + '.log'; + const logStream = fs.createWriteStream(logPath); + const cancelLogStreamListener = token.onCancellationRequested(() => logStream.destroy(new Error('cancelled'))); + await new Promise((resolve, reject) => { + logStream.on('error', reject); + logStream.on('open', resolve); + }).finally(() => { + cancelLogStreamListener.dispose(); + }); + + const localAppProcess = cp.spawn(installation.path, { + detached: true, + stdio: ['ignore', logStream, logStream], + env: { + ...process.env, + GITPOD_HOST: gitpodHost, + GITPOD_LCA_SSH_CONFIG: configFile, + GITPOD_LCA_API_PORT: String(apiPort), + GITPOD_LCA_AUTO_TUNNEL: String(false), + GITPOD_LCA_AUTH_REDIRECT_URL: `${vscode.env.uriScheme}://${this.context.extension.id}${LocalApp.AUTH_COMPLETE_PATH}`, + GITPOD_LCA_VERBOSE: String(vscode.workspace.getConfiguration('gitpod').get('verbose', false)), + GITPOD_LCA_TIMEOUT: String(vscode.workspace.getConfiguration('gitpod').get('timeout', '3h')) + } + }); + localAppProcess.unref(); + const cancelLocalAppProcessListener = token.onCancellationRequested(() => localAppProcess.kill()); + const pid = await new Promise((resolve, reject) => { + localAppProcess.on('error', reject); + localAppProcess.on('exit', code => reject(new Error('unexpectedly exit with code: ' + code))); + localAppProcess.on('spawn', () => resolve(localAppProcess.pid!)); + }).finally(() => { + cancelLocalAppProcessListener.dispose(); + }); + + this.logger.info(`the local app has been stared: ${JSON.stringify({ pid, log: vscode.Uri.file(logPath).toString() }, undefined, 2)}`); + return { gitpodHost, configFile, apiPort, pid, logPath }; + } catch (e) { + this.logger.error(`failed to start the local app: ${e}`); + throw e; + } + } + + /** + * Important: it should not call the local app to manage in 30sec + */ + private async ensureLocalApp(gitpodHost: string, configKey: string, installationKey: string, token: vscode.CancellationToken): Promise { + let config = this.context.globalState.get(configKey); + let installation = this.context.globalState.get(installationKey); + + if (config && checkRunning(config?.pid) !== true) { + config = undefined; + } + + const gitpodConfig = vscode.workspace.getConfiguration('gitpod'); + const configuredInstallationPath = gitpodConfig.get('installationPath'); + if (configuredInstallationPath) { + if (installation && installation.path !== configuredInstallationPath) { + this.logger.info(`the local app is different from configured, switching: ${JSON.stringify({ installed: installation.path, configured: configuredInstallationPath }, undefined, 2)}`); + installation = undefined; + if (config) { + try { + process.kill(config.pid); + } catch (e) { + this.logger.error(`failed to kill the outdated local app (pid: ${config.pid}): ${e}`); + } + } + config = undefined; + } + if (config) { + return config; + } + await fs.promises.access(configuredInstallationPath, fs.constants.X_OK); + throwIfCancelled(token); + installation = { path: configuredInstallationPath, etag: null }; + await this.context.globalState.update(installationKey, installation); + throwIfCancelled(token); + } else { + let download: Response | Error; + try { + download = await this.downloadLocalApp(gitpodHost); + throwIfCancelled(token); + if (!download.ok) { + download = new Error(`unexpected download response ${download.statusText} (${download.status})`); + } + } catch (e) { + download = e; + } + if (installation) { + const upgrade = !(download instanceof Error) && { etag: download.headers.get('etag'), url: download.url }; + if (upgrade && upgrade.etag && upgrade.etag !== installation.etag) { + this.logger.info(`the local app is outdated, upgrading: ${JSON.stringify({ installation, upgrade }, undefined, 2)}`); + installation = undefined; + if (config) { + try { + process.kill(config.pid); + } catch (e) { + this.logger.error(`failed to kill the outdated local app (pid: ${config.pid}): ${e}`); + } + } + config = undefined; + } + } + if (config) { + return config; + } + if (installation) { + try { + await fs.promises.access(installation.path, fs.constants.X_OK); + } catch { + installation = undefined; + } + throwIfCancelled(token); + } + if (!installation) { + if (download instanceof Error) { + throw download; + } + installation = await this.installLocalApp(download, token); + await this.context.globalState.update(installationKey, installation); + throwIfCancelled(token); + } + } + config = await this.startLocalApp(gitpodHost, installation, token); + await this.context.globalState.update(configKey, config); + throwIfCancelled(token); + return config; + } + + private async withLocalApp(gitpodHost: string, op: (client: LocalAppClient, config: LocalAppConfig) => Promise, token?: vscode.CancellationToken): Promise { + const gitpodAuthority = vscode.Uri.parse(gitpodHost).authority; + const configKey = 'config/' + gitpodAuthority; + const installationKey = 'installation/' + gitpodAuthority; + const config = await this.withLock(gitpodAuthority, token => + this.ensureLocalApp(gitpodHost, configKey, installationKey, token) + , installLockTimeout, token); + throwIfCancelled(token); + while (true) { + const client = new LocalAppClient('http://localhost:' + config.apiPort, { transport: NodeHttpTransport() }); + try { + const result = await op(client, config); + throwIfCancelled(token); + return result; + } catch (e) { + throwIfCancelled(token); + const running = checkRunning(config.pid); + if (running === true && (e.code === grpc.Code.Unavailable || e.code === grpc.Code.Unknown)) { + this.logger.info(`the local app (pid: ${config.pid}) is running, but the api endpoint is not ready: ${e}`); + this.logger.info(`retying again after 1s delay...`); + await new Promise(resolve => setTimeout(resolve, 1000)); + throwIfCancelled(token); + continue; + } + if (running !== true) { + this.logger.info(`the local app (pid: ${config.pid}) is not running: ${running}`); + } + this.logger.error(`failed to access the local app: ${e}`); + throw e; + } + } + } + + public async handleUri(uri: vscode.Uri) { + if (uri.path === LocalApp.AUTH_COMPLETE_PATH) { + this.logger.info('auth completed'); + return; + } + this.logger.info('open workspace window: ' + uri.toString()); + const params: SSHConnectionParams = JSON.parse(uri.query); + let resolvedConfig: LocalAppConfig | undefined; + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + cancellable: true, + title: `Connecting to Gitpod workspace: ${params.workspaceId}` + }, async (_, token) => { + const connection = await this.withLocalApp(params.gitpodHost, (client, config) => { + resolvedConfig = config; + const request = new ResolveSSHConnectionRequest(); + request.setInstanceId(params.instanceId); + request.setWorkspaceId(params.workspaceId); + return new Promise((resolve, reject) => + client.resolveSSHConnection(request, (e, r) => r ? resolve(r) : reject(e)) + ); + }, token); + + const config = vscode.workspace.getConfiguration('remote.SSH'); + const defaultExtensions = config.get('defaultExtensions') || []; + if (defaultExtensions.indexOf('gitpod.gitpod-remote-ssh') === -1) { + defaultExtensions.unshift('gitpod.gitpod-remote-ssh'); + await config.update('defaultExtensions', defaultExtensions, vscode.ConfigurationTarget.Global); + } + // TODO(ak) notify a user about config file changes? + const gitpodConfigFile = connection.getConfigFile(); + const currentConfigFile = config.get('configFile'); + if (currentConfigFile === gitpodConfigFile) { + // invalidate cached SSH targets from the current config file + await config.update('configFile', undefined, vscode.ConfigurationTarget.Global); + } + await config.update('configFile', gitpodConfigFile, vscode.ConfigurationTarget.Global); + // TODO(ak) ensure that vscode.ssh-remote is installed + await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.parse(`vscode-remote://ssh-remote+${connection.getHost()}${uri.path || '/'}`), { + forceNewWindow: true + }); + }); + } catch (e) { + const seeLogs = 'See Logs'; + vscode.window.showErrorMessage(`Failed to connect to Gitpod workspace ${params.workspaceId}: ${e}`, seeLogs).then(async result => { + if (result !== seeLogs) { + return; + } + this.logger.show(); + if (resolvedConfig) { + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(resolvedConfig.logPath)); + vscode.window.showTextDocument(document); + } + }); + this.logger.error(`failed to open uri: ${e}`); + throw e; + } + } + + public async autoTunnelCommand(gitpodHost: string, instanceId: string, enabled: boolean) { + try { + await this.withLocalApp(gitpodHost, client => { + const request = new AutoTunnelRequest(); + request.setInstanceId(instanceId); + request.setEnabled(enabled); + return new Promise((resolve, reject) => + client.autoTunnel(request, (e, r) => r ? resolve(undefined) : reject(e)) + ); + }); + } catch (e) { + this.logger.error('failed to disable auto tunneling', e); + } + } +} diff --git a/extensions/gitpod/src/settingsSync.ts b/extensions/gitpod/src/settingsSync.ts new file mode 100644 index 0000000000000..9617ec6c6655c --- /dev/null +++ b/extensions/gitpod/src/settingsSync.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import TelemetryReporter from './telemetryReporter'; + +interface ConfigurationSyncStore { + url: string; + insidersUrl: string; + stableUrl: string; + canSwitch: boolean; + authenticationProviders: Record; +} + +function getGitpodSyncProviderConfig(serviceUrl: string): ConfigurationSyncStore { + const syncStoreURL = `${new URL(serviceUrl).toString().replace(/\/$/, '')}/code-sync`; + return { + url: syncStoreURL, + stableUrl: syncStoreURL, + insidersUrl: syncStoreURL, + canSwitch: false, + authenticationProviders: { + gitpod: { + scopes: [ + 'function:accessCodeSyncStorage', + 'function:getLoggedInUser', + 'function:getGitpodTokenScopes', + 'resource:default' + ] + } + } + }; +} + +/** + * Updates the VS Code context to reflect whether the user added Gitpod as their Settings Sync provider. + */ +export async function updateSyncContext(): Promise { + const config = vscode.workspace.getConfiguration(); + const syncProviderConfig = config.get('configurationSync.store'); + const serviceUrl = config.get('gitpod.host')!; + const gitpodSyncProviderConfig = getGitpodSyncProviderConfig(serviceUrl); + const addedSyncProvider = !!syncProviderConfig && JSON.stringify(syncProviderConfig) === JSON.stringify(gitpodSyncProviderConfig); + await vscode.commands.executeCommand('setContext', 'gitpod.addedSyncProvider', addedSyncProvider); + return addedSyncProvider; +} + +/** + * Adds an authentication provider as a possible provider for code sync. + * It adds some key configuration to the user settings, so that the user can choose the Gitpod provider when deciding what to use with setting sync. + * @param enabled - indicates whether to add or remove the configuration + */ +export async function enableSettingsSync(enabled: boolean, telemetry: TelemetryReporter): Promise { + const promptToCloseAll = async () => { + const action = 'Close this window'; + const result = await vscode.window.showInformationMessage('Restart VS Code (close all windows) for the new Settings Sync configuration to take effect.', action); + if (result === action) { + await vscode.commands.executeCommand('workbench.action.closeWindow'); + } + }; + + let newSyncProviderConfig: ConfigurationSyncStore | undefined; + let newIgnoredSettingsConfig: string[] | undefined; + const config = vscode.workspace.getConfiguration(); + const currentSyncProviderConfig: ConfigurationSyncStore | undefined = config.get('configurationSync.store'); + const currentIgnoredSettingsConfig: string[] | undefined = config.get('settingsSync.ignoredSettings'); + const serviceUrl = config.get('gitpod.host')!; + const gitpodSyncProviderConfig = getGitpodSyncProviderConfig(serviceUrl); + if (enabled) { + if (JSON.stringify(currentSyncProviderConfig) === JSON.stringify(gitpodSyncProviderConfig)) { + return; + } + newSyncProviderConfig = gitpodSyncProviderConfig; + newIgnoredSettingsConfig = currentIgnoredSettingsConfig ?? []; + if (!newIgnoredSettingsConfig.find(s => s === 'configurationSync.store')) { + newIgnoredSettingsConfig.push('configurationSync.store'); + } + } else { + if (currentSyncProviderConfig === undefined) { + return; + } + newSyncProviderConfig = undefined; + newIgnoredSettingsConfig = currentIgnoredSettingsConfig?.filter(s => s !== 'configurationSync.store'); + } + + await config.update('settingsSync.ignoredSettings', newIgnoredSettingsConfig, vscode.ConfigurationTarget.Global); + await config.update('configurationSync.store', newSyncProviderConfig, vscode.ConfigurationTarget.Global); + + telemetry.sendTelemetryEvent('gitpod_desktop_settings_sync', { enabled: String(enabled) }); + + await promptToCloseAll(); +} diff --git a/extensions/gitpod/src/telemetryReporter.ts b/extensions/gitpod/src/telemetryReporter.ts new file mode 100644 index 0000000000000..95e8392bb911f --- /dev/null +++ b/extensions/gitpod/src/telemetryReporter.ts @@ -0,0 +1,46 @@ +import { AppenderData, BaseTelemetryAppender, BaseTelemetryClient, BaseTelemetryReporter } from './common/telemetry'; +import SegmentAnalytics from 'analytics-node'; +import * as os from 'os'; +import * as vscode from 'vscode'; + +const analyticsClientFactory = async (key: string): Promise => { + let segmentAnalyticsClient = new SegmentAnalytics(key); + + // Sets the analytics client into a standardized form + const telemetryClient: BaseTelemetryClient = { + logEvent: (eventName: string, data?: AppenderData) => { + try { + segmentAnalyticsClient.track({ + anonymousId: vscode.env.machineId, + event: eventName, + properties: data?.properties + }); + } catch (e: any) { + throw new Error('Failed to log event to app analytics!\n' + e.message); + } + }, + logException: (_exception: Error, _data?: AppenderData) => { + throw new Error('Failed to log exception to app analytics!\n'); + }, + flush: async () => { + try { + // Types are oudated, flush does return a promise + await segmentAnalyticsClient.flush(); + } catch (e: any) { + throw new Error('Failed to flush app analytics!\n' + e.message); + } + } + }; + return telemetryClient; +}; + +export default class TelemetryReporter extends BaseTelemetryReporter { + constructor(extensionId: string, extensionVersion: string, key: string) { + const appender = new BaseTelemetryAppender(key, (key) => analyticsClientFactory(key)); + super(extensionId, extensionVersion, appender, { + release: os.release(), + platform: os.platform(), + architecture: os.arch(), + }); + } +} diff --git a/extensions/gitpod/tsconfig.json b/extensions/gitpod/tsconfig.json index 13d9b9033b070..9bc718f1cf2c6 100644 --- a/extensions/gitpod/tsconfig.json +++ b/extensions/gitpod/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "./out", - "rootDir": "./src", "esModuleInterop": true - } + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts" + ] } diff --git a/extensions/yarn.lock b/extensions/yarn.lock index d3f6c5768ac1d..1174ca1277e6c 100644 --- a/extensions/yarn.lock +++ b/extensions/yarn.lock @@ -160,6 +160,16 @@ component-type "^1.2.1" join-component "^1.1.0" +"@types/analytics-node@^3.1.8": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/analytics-node/-/analytics-node-3.1.8.tgz#4b66f8725b1081caacf7a5c69793a03a6d6edb13" + integrity sha512-EivM5lsxvxQLLf6G1VEunX1C6M17AXsF2wxETBZooQbS5Tby3aO1fXyvrS9iZBiVsxHEcPSH6pdKuR6+S58kgQ== + +"@types/crypto-js@4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.1.1.tgz#602859584cecc91894eb23a4892f38cfa927890d" + integrity sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA== + "@types/long@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" @@ -267,6 +277,20 @@ analytics-node@^4.0.1: remove-trailing-slash "^0.1.0" uuid "^3.2.1" +analytics-node@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/analytics-node/-/analytics-node-6.0.0.tgz#8dd1b9a8f966e7b0a5a5f408030f1c6a137bff9b" + integrity sha512-qhwB5Fl/ps7VTg1/RnD3qJohceSHUjzTBqNn3DCmQZu/AdgPbGPeNFYu2o3xIuIyq+xZElrv0Do0b/zuGxBL9g== + dependencies: + "@segment/loosely-validate-event" "^2.0.0" + axios "^0.21.4" + axios-retry "3.2.0" + lodash.isstring "^4.0.1" + md5 "^2.2.1" + ms "^2.0.0" + remove-trailing-slash "^0.1.0" + uuid "^8.3.2" + ansi-color@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ansi-color/-/ansi-color-0.2.1.tgz#3e75c037475217544ed763a8db5709fa9ae5bf9a" @@ -296,6 +320,13 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +axios-retry@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-3.2.0.tgz#eb48e72f90b177fde62329b2896aa8476cfb90ba" + integrity sha512-RK2cLMgIsAQBDhlIsJR5dOhODPigvel18XUv1dDXW+4k1FzebyfRk+C+orot6WPZOYFKSfhLwHPwVmTVOODQ5w== + dependencies: + is-retry-allowed "^1.1.0" + axios-retry@^3.0.2: version "3.1.9" resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-3.1.9.tgz#6c30fc9aeb4519aebaec758b90ef56fa03fe72e8" @@ -310,6 +341,13 @@ axios@^0.21.1: dependencies: follow-redirects "^1.10.0" +axios@^0.21.4: + version "0.21.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== + dependencies: + follow-redirects "^1.14.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -423,6 +461,11 @@ crypt@0.0.2: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= +crypto-js@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf" + integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== + cson-parser@^1.3.3: version "1.3.5" resolved "https://registry.yarnpkg.com/cson-parser/-/cson-parser-1.3.5.tgz#7ec675e039145533bf2a6a856073f1599d9c2d24" @@ -502,6 +545,11 @@ follow-redirects@^1.10.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381" integrity sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA== +follow-redirects@^1.14.0: + version "1.14.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" + integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== + form-data@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" @@ -680,13 +728,6 @@ ms@^2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -node-fetch@2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== - dependencies: - whatwg-url "^5.0.0" - node-addon-api@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" @@ -736,6 +777,13 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= +pkce-challenge@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-3.0.0.tgz#0b01693cca7532609d0288b8276594a95033fab4" + integrity sha512-sQ8sJJJuLhA5pFnoxayMCrFnBMNj7DDpa+TWxOXl4B24oXHlVSADi/3Bowm66QuzWkBuF6DhmaelCdlC2JKwsg== + dependencies: + crypto-js "^4.1.1" + process@^0.10.0: version "0.10.1" resolved "https://registry.yarnpkg.com/process/-/process-0.10.1.tgz#842457cc51cfed72dc775afeeafb8c6034372725"