diff --git a/src/server_manager/cloud/digitalocean_api.ts b/src/server_manager/cloud/digitalocean_api.ts index f98b86de..68099068 100644 --- a/src/server_manager/cloud/digitalocean_api.ts +++ b/src/server_manager/cloud/digitalocean_api.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as errors from '../infrastructure/errors'; +import * as errors from '../infrastructure/custom_error'; export interface DigitalOceanDropletSpecification { installCommand: string; @@ -65,7 +65,7 @@ export type RegionInfo = Readonly<{ // Marker class for errors due to network or authentication. // See below for more details on when this is raised. -export class XhrError extends errors.OutlineError { +export class XhrError extends errors.CustomError { constructor() { // No message because XMLHttpRequest.onerror provides no useful info. super(); diff --git a/src/server_manager/infrastructure/custom_error.ts b/src/server_manager/infrastructure/custom_error.ts new file mode 100644 index 00000000..1250836a --- /dev/null +++ b/src/server_manager/infrastructure/custom_error.ts @@ -0,0 +1,23 @@ +// Copyright 2018 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export class CustomError extends Error { + constructor(message?: string) { + // ref: + // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget + super(message); // 'Error' breaks prototype chain here + Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain + this.name = new.target.name; + } +} diff --git a/src/server_manager/infrastructure/errors.ts b/src/server_manager/infrastructure/errors.ts deleted file mode 100644 index e598465a..00000000 --- a/src/server_manager/infrastructure/errors.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2018 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type {HttpResponse} from './path_api'; - -export class OutlineError extends Error { - constructor(message?: string) { - // ref: - // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget - super(message); // 'Error' breaks prototype chain here - Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain - this.name = new.target.name; - } -} - -// Error thrown when a shadowbox server cannot be reached (e.g. due to Firewall) -export class UnreachableServerError extends OutlineError { - constructor(message?: string) { - super(message); - } -} - -// Error thrown when monitoring an installation that the user canceled. -export class ServerInstallCanceledError extends OutlineError { - constructor(message?: string) { - super(message); - } -} - -// Error thrown when server installation failed. -export class ServerInstallFailedError extends OutlineError { - constructor(message?: string) { - super(message); - } -} - -// Thrown when a Shadowbox API request fails. -export class ServerApiError extends OutlineError { - constructor(message: string, public readonly response?: HttpResponse) { - super(message); - } - - // Returns true if no response was received, i.e. a network error was encountered. - // Can be used to distinguish between client and server-side issues. - isNetworkError() { - return !this.response; - } -} diff --git a/src/server_manager/infrastructure/path_api.ts b/src/server_manager/infrastructure/path_api.ts index ee6dd00e..3d0123e3 100644 --- a/src/server_manager/infrastructure/path_api.ts +++ b/src/server_manager/infrastructure/path_api.ts @@ -21,7 +21,7 @@ // with the Structured Clone algorithm so that they can be passed between // the Electron and Renderer processes. -import * as errors from './errors'; +import {CustomError} from './custom_error'; export interface HttpRequest { url: string; @@ -38,6 +38,19 @@ export interface HttpResponse { // A Fetcher provides the HTTP client functionality for PathApi. export type Fetcher = (request: HttpRequest) => Promise; +// Thrown when an API request fails. +export class ServerApiError extends CustomError { + constructor(message: string, public readonly response?: HttpResponse) { + super(message); + } + + // Returns true if no response was received, i.e. a network error was encountered. + // Can be used to distinguish between client and server-side issues. + isNetworkError() { + return !this.response; + } +} + /** * Provides access to an HTTP API of the kind exposed by the Shadowbox server. * @@ -106,12 +119,10 @@ export class PathApiClient { try { response = await this.fetcher(request); } catch (e) { - throw new errors.ServerApiError( - `API request to ${path} failed due to network error: ${e.message}` - ); + throw new ServerApiError(`API request to ${path} failed due to network error: ${e.message}`); } if (response.status < 200 || response.status >= 300) { - throw new errors.ServerApiError( + throw new ServerApiError( `API request to ${path} failed with status ${response.status}`, response ); diff --git a/src/server_manager/model/server.ts b/src/server_manager/model/server.ts index c6424f28..02248ee6 100644 --- a/src/server_manager/model/server.ts +++ b/src/server_manager/model/server.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import {CustomError} from '../infrastructure/custom_error'; import {CloudLocation} from './location'; export interface Server { @@ -104,12 +105,26 @@ export interface ManualServer extends Server { forget(): void; } +// Error thrown when monitoring an installation that the user canceled. +export class ServerInstallCanceledError extends CustomError { + constructor(message?: string) { + super(message); + } +} + +// Error thrown when server installation failed. +export class ServerInstallFailedError extends CustomError { + constructor(message?: string) { + super(message); + } +} + // Managed servers are servers created by the Outline Manager through our // "magic" user experience, e.g. DigitalOcean. export interface ManagedServer extends Server { // Yields how far installation has progressed (0.0 to 1.0). - // Exits when installation has completed. - // Throws if installation fails or is canceled. + // Exits when installation has completed. Throws ServerInstallFailedError or + // ServerInstallCanceledError if installation fails or is canceled. monitorInstallProgress(): AsyncGenerator; // Returns server host object. getHost(): ManagedServerHost; diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index d12cba2a..b7a37847 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -16,12 +16,12 @@ import * as sentry from '@sentry/electron'; import * as semver from 'semver'; import * as digitalocean_api from '../cloud/digitalocean_api'; -import * as errors from '../infrastructure/errors'; +import * as path_api from '../infrastructure/path_api'; import {sleep} from '../infrastructure/sleep'; import * as accounts from '../model/accounts'; import * as digitalocean from '../model/digitalocean'; import * as gcp from '../model/gcp'; -import * as server from '../model/server'; +import * as server_model from '../model/server'; import {DisplayDataAmount, displayDataAmountToBytes} from './data_formatting'; import {filterOptions, getShortName} from './location_formatting'; @@ -32,6 +32,7 @@ import type {CloudLocation} from '../model/location'; import type {AppRoot, ServerListEntry} from './ui_components/app-root'; import type {FeedbackDetail} from './ui_components/outline-feedback-dialog'; import type {DisplayAccessKey, ServerView} from './ui_components/outline-server-view'; +import {CustomError} from '../infrastructure/custom_error'; // The Outline DigitalOcean team's referral code: // https://www.digitalocean.com/help/referral-program/ @@ -45,7 +46,9 @@ const MAX_ACCESS_KEY_DATA_LIMIT_BYTES = 50 * 10 ** 9; // 50GB const CANCELLED_ERROR = new Error('Cancelled'); export const LAST_DISPLAYED_SERVER_STORAGE_KEY = 'lastDisplayedServer'; -function displayDataAmountToDataLimit(dataAmount: DisplayDataAmount): server.DataLimit | null { +function displayDataAmountToDataLimit( + dataAmount: DisplayDataAmount +): server_model.DataLimit | null { if (!dataAmount) { return null; } @@ -56,12 +59,12 @@ function displayDataAmountToDataLimit(dataAmount: DisplayDataAmount): server.Dat // Compute the suggested data limit based on the server's transfer capacity and number of access // keys. async function computeDefaultDataLimit( - server: server.Server, - accessKeys?: server.AccessKey[] -): Promise { + server: server_model.Server, + accessKeys?: server_model.AccessKey[] +): Promise { try { // Assume non-managed servers have a data transfer capacity of 1TB. - let serverTransferCapacity: server.DataAmount = {terabytes: 1}; + let serverTransferCapacity: server_model.DataAmount = {terabytes: 1}; if (isManagedServer(server)) { serverTransferCapacity = server.getHost().getMonthlyOutboundTransferLimit() ?? serverTransferCapacity; @@ -107,24 +110,33 @@ async function showHelpBubblesOnce(serverView: ServerView) { } } -function isManagedServer(testServer: server.Server): testServer is server.ManagedServer { - return !!(testServer as server.ManagedServer).getHost; +function isManagedServer( + testServer: server_model.Server +): testServer is server_model.ManagedServer { + return !!(testServer as server_model.ManagedServer).getHost; } -function isManualServer(testServer: server.Server): testServer is server.ManualServer { - return !!(testServer as server.ManualServer).forget; +function isManualServer(testServer: server_model.Server): testServer is server_model.ManualServer { + return !!(testServer as server_model.ManualServer).forget; +} + +// Error thrown when a shadowbox server cannot be reached (e.g. due to Firewall) +class UnreachableServerError extends CustomError { + constructor(message?: string) { + super(message); + } } export class App { private digitalOceanAccount: digitalocean.Account; private gcpAccount: gcp.Account; - private selectedServer: server.Server; - private idServerMap = new Map(); + private selectedServer: server_model.Server; + private idServerMap = new Map(); constructor( private appRoot: AppRoot, private readonly version: string, - private manualServerRepository: server.ManualServerRepository, + private manualServerRepository: server_model.ManualServerRepository, private cloudAccounts: accounts.CloudAccounts ) { appRoot.setAttribute('outline-version', this.version); @@ -231,7 +243,7 @@ export class App { // Remove the progress indicator. manualServerEntryEl.showConnection = false; // Display either error dialog or feedback depending on error type. - if (e instanceof errors.UnreachableServerError) { + if (e instanceof UnreachableServerError) { const errorTitle = appRoot.localize('error-server-unreachable-title'); const errorMessage = appRoot.localize('error-server-unreachable'); this.appRoot.showManualServerError(errorTitle, errorMessage); @@ -343,7 +355,7 @@ export class App { private async loadDigitalOceanAccount( digitalOceanAccount: digitalocean.Account - ): Promise { + ): Promise { if (!digitalOceanAccount) { return []; } @@ -378,7 +390,7 @@ export class App { this.appRoot.showError(this.appRoot.localize('error-do-warning', 'message', status.warning)); } - private async loadGcpAccount(gcpAccount: gcp.Account): Promise { + private async loadGcpAccount(gcpAccount: gcp.Account): Promise { if (!gcpAccount) { return []; } @@ -421,7 +433,7 @@ export class App { } } - private makeServerListEntry(accountId: string, server: server.Server): ServerListEntry { + private makeServerListEntry(accountId: string, server: server_model.Server): ServerListEntry { return { id: server.getId(), accountId, @@ -430,7 +442,7 @@ export class App { }; } - private makeDisplayName(server: server.Server): string { + private makeDisplayName(server: server_model.Server): string { let name = server.getName() ?? server.getHostnameForAccessKeys(); if (!name) { let cloudLocation = null; @@ -443,7 +455,7 @@ export class App { return name; } - private addServer(accountId: string, server: server.Server): void { + private addServer(accountId: string, server: server_model.Server): void { console.log('Loading server', server); this.idServerMap.set(server.getId(), server); const serverEntry = this.makeServerListEntry(accountId, server); @@ -463,7 +475,7 @@ export class App { /* empty */ } } catch (error) { - if (error instanceof errors.ServerInstallCanceledError) { + if (error instanceof server_model.ServerInstallCanceledError) { // User clicked "Cancel" on the loading screen. return; } @@ -488,13 +500,13 @@ export class App { } } - private updateServerEntry(server: server.Server): void { + private updateServerEntry(server: server_model.Server): void { this.appRoot.serverList = this.appRoot.serverList.map((ds) => ds.id === server.getId() ? this.makeServerListEntry(ds.accountId, server) : ds ); } - private getServerById(serverId: string): server.Server { + private getServerById(serverId: string): server_model.Server { return this.idServerMap.get(serverId); } @@ -773,14 +785,14 @@ export class App { return this.appRoot.localize('server-name', 'serverLocation', placeName); } - public showServer(server: server.Server): void { + public showServer(server: server_model.Server): void { this.selectedServer = server; this.appRoot.selectedServerId = server.getId(); localStorage.setItem(LAST_DISPLAYED_SERVER_STORAGE_KEY, server.getId()); this.appRoot.showServerView(); } - private async updateServerView(server: server.Server): Promise { + private async updateServerView(server: server_model.Server): Promise { if (await server.isHealthy()) { this.setServerManagementView(server); } else { @@ -789,7 +801,7 @@ export class App { } // Show the server management screen. Assumes the server is healthy. - private async setServerManagementView(server: server.Server): Promise { + private async setServerManagementView(server: server_model.Server): Promise { // Show view and initialize fields from selectedServer. const view = await this.appRoot.getServerView(server.getId()); const version = server.getVersion(); @@ -824,7 +836,7 @@ export class App { view.metricsEnabled = server.getMetricsEnabled(); - // Asynchronously load "My Connection" and other access keys in order to no block showing the + // Asynchronously load "My Connection" and other access keys in order to not block showing the // server. setTimeout(async () => { this.showMetricsOptInWhenNeeded(server); @@ -848,7 +860,7 @@ export class App { }, 0); } - private async setServerUnreachableView(server: server.Server): Promise { + private async setServerUnreachableView(server: server_model.Server): Promise { // Display the unreachable server state within the server view. const serverId = server.getId(); const serverView = await this.appRoot.getServerView(serverId); @@ -858,7 +870,7 @@ export class App { }; } - private async setServerProgressView(server: server.ManagedServer): Promise { + private async setServerProgressView(server: server_model.ManagedServer): Promise { const view = await this.appRoot.getServerView(server.getId()); view.serverName = this.makeDisplayName(server); view.selectedPage = 'progressView'; @@ -871,7 +883,7 @@ export class App { } } - private showMetricsOptInWhenNeeded(selectedServer: server.Server) { + private showMetricsOptInWhenNeeded(selectedServer: server_model.Server) { const showMetricsOptInOnce = () => { // Sanity check to make sure the running server is still displayed, i.e. // it hasn't been deleted. @@ -901,7 +913,7 @@ export class App { } } - private async refreshTransferStats(selectedServer: server.Server, serverView: ServerView) { + private async refreshTransferStats(selectedServer: server_model.Server, serverView: ServerView) { try { const usageMap = await selectedServer.getDataUsage(); const keyTransfers = [...usageMap.values()]; @@ -928,14 +940,16 @@ export class App { // up and trigger a Sentry report. The exception is network errors, about which we can't // do much (note: ShadowboxServer generates a breadcrumb for failures regardless which // will show up when someone explicitly submits feedback). - if (e instanceof errors.ServerApiError && e.isNetworkError()) { + // TODO(fortuna): the model is leaking implementation details here. We should clean this up + // Perhaps take a more event-based approach. + if (e instanceof path_api.ServerApiError && e.isNetworkError()) { return; } throw e; } } - private showTransferStats(selectedServer: server.Server, serverView: ServerView) { + private showTransferStats(selectedServer: server_model.Server, serverView: ServerView) { this.refreshTransferStats(selectedServer, serverView); // Get transfer stats once per minute for as long as server is selected. const statsRefreshRateMs = 60 * 1000; @@ -958,7 +972,7 @@ export class App { } // Converts the access key model to the format used by outline-server-view. - private convertToUiAccessKey(remoteAccessKey: server.AccessKey): DisplayAccessKey { + private convertToUiAccessKey(remoteAccessKey: server_model.AccessKey): DisplayAccessKey { return { id: remoteAccessKey.id, placeholderName: this.appRoot.localize('key', 'keyId', remoteAccessKey.id), @@ -996,7 +1010,7 @@ export class App { }); } - private async setDefaultDataLimit(limit: server.DataLimit) { + private async setDefaultDataLimit(limit: server_model.DataLimit) { if (!limit) { return; } @@ -1136,7 +1150,7 @@ export class App { // Returns promise which fulfills when the server is created successfully, // or rejects with an error message that can be displayed to the user. public async createManualServer(userInput: string): Promise { - let serverConfig: server.ManualServerConfig; + let serverConfig: server_model.ManualServerConfig; try { serverConfig = parseManualServerConfig(userInput); } catch (e) { @@ -1162,7 +1176,7 @@ export class App { // Remove inaccessible manual server from local storage if it was just created. manualServer.forget(); console.error('Manual server installed but unreachable.'); - throw new errors.UnreachableServerError(); + throw new UnreachableServerError(); } } @@ -1260,7 +1274,7 @@ export class App { } } - private cancelServerCreation(serverToCancel: server.Server): void { + private cancelServerCreation(serverToCancel: server_model.Server): void { if (!isManagedServer(serverToCancel)) { const msg = 'cannot cancel non-ManagedServer'; console.error(msg); diff --git a/src/server_manager/web_app/digitalocean_server.ts b/src/server_manager/web_app/digitalocean_server.ts index cdf6fcbf..84606c31 100644 --- a/src/server_manager/web_app/digitalocean_server.ts +++ b/src/server_manager/web_app/digitalocean_server.ts @@ -13,7 +13,6 @@ // limitations under the License. import {DigitalOceanSession, DropletInfo} from '../cloud/digitalocean_api'; -import * as errors from '../infrastructure/errors'; import {hexToString} from '../infrastructure/hex_encoding'; import {sleep} from '../infrastructure/sleep'; import {ValueStream} from '../infrastructure/value_stream'; @@ -114,9 +113,9 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage } if (this.installState.get() === InstallState.FAILED) { - throw new errors.ServerInstallFailedError(); + throw new server.ServerInstallFailedError(); } else if (this.installState.get() === InstallState.CANCELED) { - throw new errors.ServerInstallCanceledError(); + throw new server.ServerInstallCanceledError(); } } diff --git a/src/server_manager/web_app/gcp_account.ts b/src/server_manager/web_app/gcp_account.ts index b8186b38..6cc9ecc1 100644 --- a/src/server_manager/web_app/gcp_account.ts +++ b/src/server_manager/web_app/gcp_account.ts @@ -13,7 +13,6 @@ // limitations under the License. import * as gcp_api from '../cloud/gcp_api'; -import {ServerInstallFailedError} from '../infrastructure/errors'; import {sleep} from '../infrastructure/sleep'; import {SCRIPT} from '../install_scripts/gcp_install_script'; import * as gcp from '../model/gcp'; @@ -216,7 +215,7 @@ export class GcpAccount implements gcp.Account { ); const errors = createFirewallOperation.error?.errors; if (errors) { - throw new ServerInstallFailedError(`Firewall creation failed: ${errors}`); + throw new server.ServerInstallFailedError(`Firewall creation failed: ${errors}`); } } } @@ -278,7 +277,7 @@ export class GcpAccount implements gcp.Account { ); const errors = createInstanceOperation.error?.errors; if (errors) { - throw new ServerInstallFailedError(`Instance creation failed: ${errors}`); + throw new server.ServerInstallFailedError(`Instance creation failed: ${errors}`); } const instanceId = createInstanceOperation.targetId; diff --git a/src/server_manager/web_app/gcp_server.ts b/src/server_manager/web_app/gcp_server.ts index 32a6dead..9a565e38 100644 --- a/src/server_manager/web_app/gcp_server.ts +++ b/src/server_manager/web_app/gcp_server.ts @@ -13,7 +13,6 @@ // limitations under the License. import * as gcp_api from '../cloud/gcp_api'; -import * as errors from '../infrastructure/errors'; import {sleep} from '../infrastructure/sleep'; import {ValueStream} from '../infrastructure/value_stream'; import {Zone} from '../model/gcp'; @@ -133,7 +132,7 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer { // The IP address has not yet been reserved. return false; } - throw new errors.ServerInstallFailedError(`Static IP check failed: ${e}`); + throw new server.ServerInstallFailedError(`Static IP check failed: ${e}`); } } @@ -152,7 +151,7 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer { ); const operationErrors = createStaticIpOperation.error?.errors; if (operationErrors) { - throw new errors.ServerInstallFailedError(`Firewall creation failed: ${operationErrors}`); + throw new server.ServerInstallFailedError(`Firewall creation failed: ${operationErrors}`); } } @@ -166,9 +165,9 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer { } if (this.installState.get() === InstallState.FAILED) { - throw new errors.ServerInstallFailedError(); + throw new server.ServerInstallFailedError(); } else if (this.installState.get() === InstallState.CANCELED) { - throw new errors.ServerInstallCanceledError(); + throw new server.ServerInstallCanceledError(); } yield getCompletionFraction(this.installState.get()); } diff --git a/src/shadowbox/model/errors.ts b/src/shadowbox/model/errors.ts index ddad5a28..12a63413 100644 --- a/src/shadowbox/model/errors.ts +++ b/src/shadowbox/model/errors.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +// TODO(fortuna): Reuse CustomError from server_manager. class OutlineError extends Error { constructor(message: string) { super(message);