diff --git a/src/server_manager/cloud/digitalocean_api.ts b/src/server_manager/cloud/digitalocean_api.ts index dfc924c3..9e275516 100644 --- a/src/server_manager/cloud/digitalocean_api.ts +++ b/src/server_manager/cloud/digitalocean_api.ts @@ -89,11 +89,7 @@ export interface DigitalOceanSession { getDroplets(): Promise; } -export function createDigitalOceanSession(accessToken: string): DigitalOceanSession { - return new RestApiSession(accessToken); -} - -class RestApiSession implements DigitalOceanSession { +export class RestApiSession implements DigitalOceanSession { // Constructor takes a DigitalOcean access token, which should have // read+write permissions. constructor(public accessToken: string) {} diff --git a/src/server_manager/model/digitalocean.ts b/src/server_manager/model/digitalocean.ts new file mode 100644 index 00000000..7ed3f55f --- /dev/null +++ b/src/server_manager/model/digitalocean.ts @@ -0,0 +1,41 @@ +// Copyright 2021 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 {ManagedServer, RegionId} from "./server"; + +// Keys are cityIds like "nyc". Values are regions like ["nyc1", "nyc3"]. +export type RegionMap = { + [cityId: string]: RegionId[] +}; + +export enum Status { + ACTIVE, + EMAIL_UNVERIFIED, + MISSING_BILLING_INFORMATION, +} + +export interface Account { + // Returns the email address associated with the account. + getName(): Promise; + // Returns the status of the account. + getStatus(): Promise; + // Lists all existing Shadowboxes. If `fetchFromHost` is true, performs a network request to + // retrieve the servers; otherwise resolves with a cached server list. + listServers(fetchFromHost?: boolean): Promise; + // Return a map of regions that are available and support our target machine size. + getRegionMap(): Promise>; + // Creates a server and returning it when it becomes active (i.e. the server has + // created, not necessarily once shadowbox installation has finished). + createServer(region: RegionId, name: string): Promise; +} diff --git a/src/server_manager/model/server.ts b/src/server_manager/model/server.ts index d7a8770c..07d14e92 100644 --- a/src/server_manager/model/server.ts +++ b/src/server_manager/model/server.ts @@ -123,25 +123,6 @@ export class MonetaryCost { export type RegionId = string; -// Keys are cityIds like "nyc". Values are regions like ["nyc1", "nyc3"]. -export type RegionMap = { - [cityId: string]: RegionId[] -}; - -// Repository of ManagedServer objects. These servers are created by the server -// manager on cloud providers where we can provide a "magical" user experience, -// e.g. DigitalOcean. -export interface ManagedServerRepository { - // Lists all existing Shadowboxes. If `fetchFromHost` is true, performs a network request to - // retrieve the servers; otherwise resolves with a cached server list. - listServers(fetchFromHost?: boolean): Promise; - // Return a map of regions that are available and support our target machine size. - getRegionMap(): Promise>; - // Creates a server and returning it when it becomes active (i.e. the server has - // created, not necessarily once shadowbox installation has finished). - createServer(region: RegionId, name: string): Promise; -} - // Configuration for manual servers. This is the output emitted from the // shadowbox install script, which is needed for the manager connect to // shadowbox. diff --git a/src/server_manager/web_app/app.spec.ts b/src/server_manager/web_app/app.spec.ts index f179d09e..f547ea9b 100644 --- a/src/server_manager/web_app/app.spec.ts +++ b/src/server_manager/web_app/app.spec.ts @@ -14,18 +14,15 @@ import './ui_components/app-root.js'; -import * as digitalocean_api from '../cloud/digitalocean_api'; import * as server from '../model/server'; +import * as digitalocean from "../model/digitalocean"; import {App, LAST_DISPLAYED_SERVER_STORAGE_KEY} from './app'; -import {TokenManager} from './digitalocean_oauth'; import {AppRoot} from './ui_components/app-root'; - -const TOKEN_WITH_NO_SERVERS = 'no-server-token'; -const TOKEN_WITH_ONE_SERVER = 'one-server-token'; +import {CloudAccounts} from "./cloud_accounts"; +import {InMemoryStorage} from "../infrastructure/memory_storage"; // Define functions from preload.ts. - // tslint:disable-next-line:no-any (global as any).onUpdateDownloaded = () => {}; // tslint:disable-next-line:no-any @@ -39,7 +36,7 @@ beforeEach(() => { describe('App', () => { it('shows intro when starting with no manual servers or DigitalOcean token', async () => { const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const app = createTestApp(appRoot, new InMemoryDigitalOceanTokenManager()); + const app = createTestApp(appRoot); await app.start(); expect(appRoot.currentPage).toEqual('intro'); }); @@ -47,7 +44,7 @@ describe('App', () => { it('will not create a manual server with invalid input', async () => { // Create a new app with no existing servers or DigitalOcean token. const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const app = createTestApp(appRoot, new InMemoryDigitalOceanTokenManager()); + const app = createTestApp(appRoot); await app.start(); expect(appRoot.currentPage).toEqual('intro'); await expectAsync(app.createManualServer('bad input')).toBeRejectedWithError(); @@ -56,7 +53,7 @@ describe('App', () => { it('creates a manual server with valid input', async () => { // Create a new app with no existing servers or DigitalOcean token. const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const app = createTestApp(appRoot, new InMemoryDigitalOceanTokenManager()); + const app = createTestApp(appRoot); await app.start(); expect(appRoot.currentPage).toEqual('intro'); await app.createManualServer(JSON.stringify({certSha256: 'cert', apiUrl: 'url'})); @@ -65,26 +62,30 @@ describe('App', () => { it('initially shows servers', async () => { // Create fake servers and simulate their metadata being cached before creating the app. - const tokenManager = new InMemoryDigitalOceanTokenManager(); - tokenManager.token = TOKEN_WITH_NO_SERVERS; - const managedServerRepo = new FakeManagedServerRepository(); - const managedServer = await managedServerRepo.createServer('fake-managed-server-id'); - managedServer.apiUrl = 'fake-managed-server-api-url'; + const fakeAccount = new FakeDigitalOceanAccount(); + await fakeAccount.createServer('fake-managed-server-id'); + const cloudAccounts = makeCloudAccountsWithDoAccount(fakeAccount); + const manualServerRepo = new FakeManualServerRepository(); await manualServerRepo.addServer({certSha256: 'cert', apiUrl: 'fake-manual-server-api-url-1'}); await manualServerRepo.addServer({certSha256: 'cert', apiUrl: 'fake-manual-server-api-url-2'}); const appRoot = document.getElementById('appRoot') as unknown as AppRoot; expect(appRoot.serverList.length).toEqual(0); - const app = createTestApp(appRoot, tokenManager, manualServerRepo, managedServerRepo); + const app = createTestApp(appRoot, cloudAccounts, manualServerRepo); await app.start(); // Validate that server metadata is shown. - const managedServers = await managedServerRepo.listServers(); + const managedServers = await fakeAccount.listServers(); expect(managedServers.length).toEqual(1); const manualServers = await manualServerRepo.listServers(); expect(manualServers.length).toEqual(2); + appRoot.getServerView(''); const serverList = appRoot.serverList; + + console.log(`managedServers.length: ${managedServers.length}`); + console.log(`manualServers.length: ${manualServers.length}`); + expect(serverList.length).toEqual(manualServers.length + managedServers.length); expect(serverList).toContain(jasmine.objectContaining({id: 'fake-manual-server-api-url-1'})); expect(serverList).toContain(jasmine.objectContaining({id: 'fake-manual-server-api-url-2'})); @@ -92,9 +93,6 @@ describe('App', () => { }); it('initially shows the last selected server', async () => { - const tokenManager = new InMemoryDigitalOceanTokenManager(); - tokenManager.token = TOKEN_WITH_ONE_SERVER; - const LAST_DISPLAYED_SERVER_ID = 'fake-manual-server-api-url-1'; const manualServerRepo = new FakeManualServerRepository(); const lastDisplayedServer = @@ -102,7 +100,7 @@ describe('App', () => { await manualServerRepo.addServer({certSha256: 'cert', apiUrl: 'fake-manual-server-api-url-2'}); localStorage.setItem('lastDisplayedServer', LAST_DISPLAYED_SERVER_ID); const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const app = createTestApp(appRoot, tokenManager, manualServerRepo); + const app = createTestApp(appRoot, null, manualServerRepo); await app.start(); expect(appRoot.currentPage).toEqual('serverView'); expect(appRoot.selectedServerId).toEqual(lastDisplayedServer.getManagementApiUrl()); @@ -111,9 +109,8 @@ describe('App', () => { it('shows progress screen once DigitalOcean droplets are created', async () => { // Start the app with a fake DigitalOcean token. const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const tokenManager = new InMemoryDigitalOceanTokenManager(); - tokenManager.token = TOKEN_WITH_NO_SERVERS; - const app = createTestApp(appRoot, tokenManager); + const cloudAccounts = makeCloudAccountsWithDoAccount(new FakeDigitalOceanAccount()); + const app = createTestApp(appRoot, cloudAccounts); await app.start(); await app.createDigitalOceanServer('fakeRegion'); expect(appRoot.currentPage).toEqual('serverView'); @@ -123,12 +120,10 @@ describe('App', () => { it('shows progress screen when starting with DigitalOcean servers still being created', async () => { const appRoot = document.getElementById('appRoot') as unknown as AppRoot; - const tokenManager = new InMemoryDigitalOceanTokenManager(); - tokenManager.token = TOKEN_WITH_NO_SERVERS; - const managedSeverRepository = new FakeManagedServerRepository(); - // Manually create the server since the DO repository server factory function is synchronous. - const server = await managedSeverRepository.createServer(); - const app = createTestApp(appRoot, tokenManager, null, managedSeverRepository); + const fakeAccount = new FakeDigitalOceanAccount(); + const server = await fakeAccount.createServer(Math.random().toString()); + const cloudAccounts = makeCloudAccountsWithDoAccount(fakeAccount); + const app = createTestApp(appRoot, cloudAccounts, null); // Sets last displayed server. localStorage.setItem(LAST_DISPLAYED_SERVER_STORAGE_KEY, server.getId()); await app.start(); @@ -137,28 +132,24 @@ describe('App', () => { }); }); +function makeCloudAccountsWithDoAccount(fakeAccount: FakeDigitalOceanAccount) { + const fakeDigitalOceanAccountFactory = (token: string) => fakeAccount; + const cloudAccounts = new CloudAccounts(fakeDigitalOceanAccountFactory, new InMemoryStorage()); + cloudAccounts.connectDigitalOceanAccount('fake-access-token'); + return cloudAccounts; +} + function createTestApp( - appRoot: AppRoot, digitalOceanTokenManager: InMemoryDigitalOceanTokenManager, - manualServerRepo?: server.ManualServerRepository, - managedServerRepository?: FakeManagedServerRepository) { + appRoot: AppRoot, cloudAccounts?: CloudAccounts, + manualServerRepo?: server.ManualServerRepository) { const VERSION = '0.0.1'; - const fakeDigitalOceanSessionFactory = (accessToken: string) => { - return new FakeDigitalOceanSession(accessToken); - }; - const fakeDigitalOceanServerRepositoryFactory = - (session: digitalocean_api.DigitalOceanSession) => { - const repo = managedServerRepository || new FakeManagedServerRepository(); - if (session.accessToken === TOKEN_WITH_ONE_SERVER) { - repo.createServer(); // OK to ignore promise as the fake implementation is synchronous. - } - return repo; - }; + if (!cloudAccounts) { + cloudAccounts = new CloudAccounts((token: string) => new FakeDigitalOceanAccount(), new InMemoryStorage()); + } if (!manualServerRepo) { manualServerRepo = new FakeManualServerRepository(); } - return new App( - appRoot, VERSION, fakeDigitalOceanSessionFactory, fakeDigitalOceanServerRepositoryFactory, - manualServerRepo, digitalOceanTokenManager); + return new App(appRoot, VERSION, manualServerRepo, cloudAccounts); } class FakeServer implements server.Server { @@ -272,46 +263,6 @@ class FakeManualServerRepository implements server.ManualServerRepository { } } -class InMemoryDigitalOceanTokenManager implements TokenManager { - public token: string; - getStoredToken(): string { - return this.token; - } - removeTokenFromStorage() { - this.token = null; - } - writeTokenToStorage(token: string) { - this.token = token; - } -} - -class FakeDigitalOceanSession implements digitalocean_api.DigitalOceanSession { - constructor(public accessToken: string) {} - - // Return fake account data. - getAccount() { - return Promise.resolve( - {email: 'fake@email.com', uuid: 'fake', email_verified: true, status: 'active'}); - } - - // Return an empty list of droplets by default. - getDropletsByTag = (tag: string) => Promise.resolve([]); - - // Return an empty list of regions by default. - getRegionInfo = () => Promise.resolve([]); - - // Other methods do not yet need implementations for tests to pass. - createDroplet = - (displayName: string, region: string, publicKeyForSSH: string, - dropletSpec: digitalocean_api.DigitalOceanDropletSpecification) => - Promise.reject(new Error('createDroplet not implemented')); - deleteDroplet = (dropletId: number) => Promise.reject(new Error('deleteDroplet not implemented')); - getDroplet = (dropletId: number) => Promise.reject(new Error('getDroplet not implemented')); - getDropletTags = (dropletId: number) => - Promise.reject(new Error('getDropletTags not implemented')); - getDroplets = () => Promise.reject(new Error('getDroplets not implemented')); -} - class FakeManagedServer extends FakeServer implements server.ManagedServer { constructor(id: string, private isInstalled = true) { super(id); @@ -335,8 +286,14 @@ class FakeManagedServer extends FakeServer implements server.ManagedServer { } } -class FakeManagedServerRepository implements server.ManagedServerRepository { +class FakeDigitalOceanAccount implements digitalocean.Account { private servers: server.ManagedServer[] = []; + async getName(): Promise { + return 'name'; + } + async getStatus(): Promise { + return digitalocean.Status.ACTIVE; + } listServers() { return Promise.resolve(this.servers); } diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index 4b148e71..4230876b 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -19,9 +19,9 @@ import * as digitalocean_api from '../cloud/digitalocean_api'; import * as errors from '../infrastructure/errors'; import {sleep} from '../infrastructure/sleep'; import * as server from '../model/server'; +import * as digitalocean from '../model/digitalocean'; -import {formatBytes} from './data_formatting'; -import {TokenManager} from './digitalocean_oauth'; +import {CloudAccounts} from './cloud_accounts'; import * as digitalocean_server from './digitalocean_server'; import {parseManualServerConfig} from './management_urls'; import {AppRoot, ServerListEntry} from './ui_components/app-root'; @@ -130,34 +130,24 @@ function isManualServer(testServer: server.Server): testServer is server.ManualS return !!(testServer as server.ManualServer).forget; } -function localizeDate(date: Date, language: string): string { - return date.toLocaleString(language, {year: 'numeric', month: 'long', day: 'numeric'}); -} - -type DigitalOceanSessionFactory = (accessToken: string) => digitalocean_api.DigitalOceanSession; -type DigitalOceanServerRepositoryFactory = (session: digitalocean_api.DigitalOceanSession) => - server.ManagedServerRepository; - export class App { - private digitalOceanRepository: server.ManagedServerRepository; + private digitalOceanAccount: digitalocean.Account; private selectedServer: server.Server; private idServerMap = new Map(); constructor( private appRoot: AppRoot, private readonly version: string, - private createDigitalOceanSession: DigitalOceanSessionFactory, - private createDigitalOceanServerRepository: DigitalOceanServerRepositoryFactory, private manualServerRepository: server.ManualServerRepository, - private digitalOceanTokenManager: TokenManager) { + private cloudAccounts: CloudAccounts) { appRoot.setAttribute('outline-version', this.version); appRoot.addEventListener('ConnectDigitalOceanAccountRequested', (event: CustomEvent) => { this.handleConnectDigitalOceanAccountRequest(); }); appRoot.addEventListener('CreateDigitalOceanServerRequested', (event: CustomEvent) => { - const accessToken = this.digitalOceanTokenManager?.getStoredToken(); - if (accessToken) { - this.showDigitalOceanCreateServer(accessToken); + const digitalOceanAccount = this.cloudAccounts.getDigitalOceanAccount(); + if (digitalOceanAccount) { + this.showDigitalOceanCreateServer(digitalOceanAccount); } else { console.error('Access token not found for server creation'); this.handleConnectDigitalOceanAccountRequest(); @@ -325,9 +315,11 @@ export class App { async start(): Promise { this.showIntro(); + console.log('CloudAccounts', this.cloudAccounts.getDigitalOceanAccount()); + // Load server list. Fetch manual and managed servers in parallel. await Promise.all([ - this.loadDigitalOceanServers(this.digitalOceanTokenManager?.getStoredToken()), + this.loadDigitalOceanServers(this.cloudAccounts.getDigitalOceanAccount()), this.loadManualServers() ]); @@ -341,19 +333,19 @@ export class App { } } - private async loadDigitalOceanServers(accessToken: string): Promise { - if (!accessToken) { + private async loadDigitalOceanServers(digitalOceanAccount: digitalocean.Account): + Promise { + if (!digitalOceanAccount) { return []; } try { - const doSession = this.createDigitalOceanSession(accessToken); - const doAccount = await doSession.getAccount(); - this.appRoot.adminEmail = doAccount.email; - this.digitalOceanRepository = this.createDigitalOceanServerRepository(doSession); - if (doAccount.status !== 'active') { + this.digitalOceanAccount = digitalOceanAccount; + this.appRoot.adminEmail = await this.digitalOceanAccount.getName(); + const status = await this.digitalOceanAccount.getStatus(); + if (status !== digitalocean.Status.ACTIVE) { return; } - const servers = await this.digitalOceanRepository.listServers(); + const servers = await this.digitalOceanAccount.listServers(); for (const server of servers) { this.addServer(server); } @@ -445,8 +437,8 @@ export class App { // Returns a promise that resolves when the account is active. // Throws CANCELLED_ERROR on cancellation, and the error on failure. - private async ensureActiveDigitalOceanAccount(accessToken: string): Promise { - const doSession = this.createDigitalOceanSession(accessToken); + private async ensureActiveDigitalOceanAccount(digitalOceanAccount: digitalocean.Account): + Promise { let cancelled = false; let activatingAccount = false; @@ -457,13 +449,13 @@ export class App { }; const oauthUi = this.appRoot.getDigitalOceanOauthFlow(signOutAction); while (true) { - const account = await this.digitalOceanRetry(async () => { + const status = await this.digitalOceanRetry(async () => { if (cancelled) { throw CANCELLED_ERROR; } - return await doSession.getAccount(); + return await digitalOceanAccount.getStatus(); }); - if (account.status === 'active') { + if (status === digitalocean.Status.ACTIVE) { bringToFront(); if (activatingAccount) { // Show the 'account active' screen for a few seconds if the account was activated @@ -475,7 +467,7 @@ export class App { } this.appRoot.showDigitalOceanOauthFlow(); activatingAccount = true; - if (account.email_verified) { + if (status === digitalocean.Status.MISSING_BILLING_INFORMATION) { oauthUi.showBilling(); } else { oauthUi.showEmailVerification(); @@ -531,16 +523,13 @@ export class App { }; this.appRoot.getAndShowDigitalOceanOauthFlow(handleOauthFlowCancelled); try { - const accessToken = await oauth.result; - // Save accessToken to storage. DigitalOcean tokens - // expire after 30 days, unless they are manually revoked by the user. - // After 30 days the user will have to sign into DigitalOcean again. - // Note we cannot yet use DigitalOcean refresh tokens, as they require - // a client_secret to be stored on a server and not visible to end users - // in client-side JS. More details at: + // DigitalOcean tokens expire after 30 days, unless they are manually + // revoked by the user. After 30 days the user will have to sign into + // DigitalOcean again. Note we cannot yet use DigitalOcean refresh + // tokens, as they require a client_secret to be stored on a server and + // not visible to end users in client-side JS. More details at: // https://developers.digitalocean.com/documentation/oauth/#refresh-token-flow - this.digitalOceanTokenManager.writeTokenToStorage(accessToken); - return accessToken; + return await oauth.result; } catch (error) { if (oauth.isCancelled()) { throw CANCELLED_ERROR; @@ -551,9 +540,10 @@ export class App { } private async handleConnectDigitalOceanAccountRequest(): Promise { - let accessToken: string; + let digitalOceanAccount: digitalocean.Account; try { - accessToken = await this.runDigitalOceanOauthFlow(); + const accessToken = await this.runDigitalOceanOauthFlow(); + digitalOceanAccount = this.cloudAccounts.connectDigitalOceanAccount(accessToken); } catch (error) { this.disconnectDigitalOceanAccount(); this.showIntro(); @@ -564,18 +554,18 @@ export class App { } return; } - const doServers = await this.loadDigitalOceanServers(accessToken); + const doServers = await this.loadDigitalOceanServers(digitalOceanAccount); if (doServers.length > 0) { this.showServer(doServers[0]); } else { - await this.showDigitalOceanCreateServer(accessToken); + await this.showDigitalOceanCreateServer(digitalOceanAccount); } } // Clears the credentials and returns to the intro screen. private disconnectDigitalOceanAccount(): void { - this.digitalOceanTokenManager.removeTokenFromStorage(); - this.digitalOceanRepository = null; + this.cloudAccounts.disconnectDigitalOceanAccount(); + this.digitalOceanAccount = null; for (const serverEntry of this.appRoot.serverList) { if (serverEntry.isManaged) { this.removeServer(serverEntry.id); @@ -585,9 +575,10 @@ export class App { } // Opens the screen to create a server. - private async showDigitalOceanCreateServer(accessToken: string): Promise { + private async showDigitalOceanCreateServer(digitalOceanAccount: digitalocean.Account): + Promise { try { - await this.ensureActiveDigitalOceanAccount(accessToken); + await this.ensureActiveDigitalOceanAccount(digitalOceanAccount); } catch (error) { if (this.appRoot.currentPage === 'digitalOceanOauth') { this.showIntro(); @@ -604,7 +595,7 @@ export class App { try { const regionPicker = this.appRoot.getAndShowRegionPicker(); const map = await this.digitalOceanRetry(() => { - return this.digitalOceanRepository.getRegionMap(); + return this.digitalOceanAccount.getRegionMap(); }); const locations = Object.entries(map).map(([cityId, regionIds]) => { return this.createLocationModel(cityId, regionIds); @@ -622,7 +613,7 @@ export class App { try { const serverName = this.makeLocalizedServerName(regionId); const server = await this.digitalOceanRetry(() => { - return this.digitalOceanRepository.createServer(regionId, serverName); + return this.digitalOceanAccount.createServer(regionId, serverName); }); this.addServer(server); this.showServer(server); diff --git a/src/server_manager/web_app/cloud_accounts.ts b/src/server_manager/web_app/cloud_accounts.ts new file mode 100644 index 00000000..a7d892e9 --- /dev/null +++ b/src/server_manager/web_app/cloud_accounts.ts @@ -0,0 +1,50 @@ +// Copyright 2021 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 {Account} from '../model/digitalocean'; + +type DigitalOceanAccountFactory = (accessToken: string) => Account; + +export class CloudAccounts { + private readonly DIGITALOCEAN_TOKEN_STORAGE_KEY = 'LastDOToken'; + + constructor( + private digitalOceanAccountFactory: DigitalOceanAccountFactory, + private storage = localStorage) {} + + connectDigitalOceanAccount(token: string): Account { + this.writeTokenToStorage(token); + return this.getDigitalOceanAccount(); + } + + disconnectDigitalOceanAccount(): void { + this.storage.removeItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY); + } + + getDigitalOceanAccount(): Account { + const token = this.getTokenFromStorage(); + if (token) { + return this.digitalOceanAccountFactory(token); + } + return null; + } + + private writeTokenToStorage(token: string): void { + this.storage.setItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY, token); + } + + private getTokenFromStorage(): string { + return this.storage.getItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY); + } +} diff --git a/src/server_manager/web_app/digitalocean_account.ts b/src/server_manager/web_app/digitalocean_account.ts new file mode 100644 index 00000000..31c50174 --- /dev/null +++ b/src/server_manager/web_app/digitalocean_account.ts @@ -0,0 +1,148 @@ +// Copyright 2021 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 {DigitalOceanSession, DropletInfo} from "../cloud/digitalocean_api"; +import {DigitalOceanServer, GetCityId} from "./digitalocean_server"; +import * as server from "../model/server"; +import * as crypto from "../infrastructure/crypto"; +import * as digitalocean from "../model/digitalocean"; +import * as do_install_script from "../install_scripts/do_install_script"; + +// Tag used to mark Shadowbox Droplets. +const SHADOWBOX_TAG = 'shadowbox'; +const MACHINE_SIZE = 's-1vcpu-1gb'; + +export interface ShadowboxSettings { + imageId: string; + metricsUrl: string; + sentryApiUrl?: string; + watchtowerRefreshSeconds?: number; +} + +export class DigitalOceanAccount implements digitalocean.Account { + private servers: DigitalOceanServer[] = []; + + constructor( + private digitalOcean: DigitalOceanSession, private shadowboxSettings: ShadowboxSettings, + private debugMode: boolean) {} + + async getName(): Promise { + return (await this.digitalOcean.getAccount())?.email; + } + + async getStatus(): Promise { + const account = await this.digitalOcean.getAccount(); + if (account.status === 'active') { + return digitalocean.Status.ACTIVE; + } + if (!account.email_verified) { + return digitalocean.Status.EMAIL_UNVERIFIED; + } + return digitalocean.Status.MISSING_BILLING_INFORMATION; + } + + // Return a map of regions that are available and support our target machine size. + getRegionMap(): Promise> { + return this.digitalOcean.getRegionInfo().then((regions) => { + const ret: digitalocean.RegionMap = {}; + regions.forEach((region) => { + const cityId = GetCityId(region.slug); + if (!(cityId in ret)) { + ret[cityId] = []; + } + if (region.available && region.sizes.indexOf(MACHINE_SIZE) !== -1) { + ret[cityId].push(region.slug); + } + }); + return ret; + }); + } + + // Creates a server and returning it when it becomes active. + createServer(region: server.RegionId, name: string): Promise { + console.time('activeServer'); + console.time('servingServer'); + const onceKeyPair = crypto.generateKeyPair(); + const installCommand = + getInstallScript(this.digitalOcean.accessToken, name, this.shadowboxSettings); + + const dropletSpec = { + installCommand, + size: MACHINE_SIZE, + image: 'docker-18-04', + tags: [SHADOWBOX_TAG], + }; + return onceKeyPair + .then((keyPair) => { + if (this.debugMode) { + // Strip carriage returns, which produce weird blank lines when pasted into a terminal. + console.debug( + `private key for SSH access to new droplet:\n${ + keyPair.private.replace(/\r/g, '')}\n\n` + + 'Use "ssh -i keyfile root@[ip_address]" to connect to the machine'); + } + return this.digitalOcean.createDroplet(name, region, keyPair.public, dropletSpec); + }) + .then((response) => { + return this.createDigitalOceanServer(this.digitalOcean, response.droplet); + }); + } + + listServers(fetchFromHost = true): Promise { + if (!fetchFromHost) { + return Promise.resolve(this.servers); // Return the in-memory servers. + } + return this.digitalOcean.getDropletsByTag(SHADOWBOX_TAG).then((droplets) => { + this.servers = []; + return droplets.map((droplet) => { + return this.createDigitalOceanServer(this.digitalOcean, droplet); + }); + }); + } + + // Creates a DigitalOceanServer object and adds it to the in-memory server list. + private createDigitalOceanServer(digitalOcean: DigitalOceanSession, dropletInfo: DropletInfo) { + const server = new DigitalOceanServer(digitalOcean, dropletInfo); + this.servers.push(server); + return server; + } +} + +function sanitizeDigitalOceanToken(input: string): string { + const sanitizedInput = input.trim(); + const pattern = /^[A-Za-z0-9_\/-]+$/; + if (!pattern.test(sanitizedInput)) { + throw new Error('Invalid DigitalOcean Token'); + } + return sanitizedInput; +} + +// cloudFunctions needs to define cloud::public_ip and cloud::add_tag. +function getInstallScript( + accessToken: string, name: string, shadowboxSettings: ShadowboxSettings): string { + const sanitizedAccessToken = sanitizeDigitalOceanToken(accessToken); + // TODO: consider shell escaping these variables. + return '#!/bin/bash -eu\n' + + `export DO_ACCESS_TOKEN=${sanitizedAccessToken}\n` + + (shadowboxSettings.imageId ? `export SB_IMAGE=${shadowboxSettings.imageId}\n` : '') + + (shadowboxSettings.watchtowerRefreshSeconds ? + `export WATCHTOWER_REFRESH_SECONDS=${shadowboxSettings.watchtowerRefreshSeconds}\n` : + '') + + (shadowboxSettings.sentryApiUrl ? + `export SENTRY_API_URL="${shadowboxSettings.sentryApiUrl}"\n` : + '') + + (shadowboxSettings.metricsUrl ? `export SB_METRICS_URL=${shadowboxSettings.metricsUrl}\n` : + '') + + `export SB_DEFAULT_SERVER_NAME="${name}"\n` + do_install_script.SCRIPT; +} diff --git a/src/server_manager/web_app/digitalocean_oauth.ts b/src/server_manager/web_app/digitalocean_oauth.ts deleted file mode 100644 index 1e9d5ca6..00000000 --- a/src/server_manager/web_app/digitalocean_oauth.ts +++ /dev/null @@ -1,55 +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. - -export interface TokenManager { - // Returns the Oauth token, or null if unavailable. - getStoredToken(): string; - // Writes the token to storage. - writeTokenToStorage(token: string): void; - // Removes the token from storage. - removeTokenFromStorage(): void; -} - -// TODO: this class combines URL manipulation with persistence logic. -// Consider moving the URL manipulation logic to a separate class, so we -// can pass in other implementations when the global "window" is not present. -export class DigitalOceanTokenManager implements TokenManager { - private readonly DIGITALOCEAN_TOKEN_STORAGE_KEY = 'LastDOToken'; - - // Searches the current URL (post-OAuth) and local storage for a DigitalOcean - // access token. The token is not checked for validity as this would require - // an extra roundtrip to DigitalOcean. - getStoredToken(): string { - const tokenFromStorage = this.getTokenFromStorage(); - if (tokenFromStorage) { - console.info('found access token in local storage'); - return tokenFromStorage; - } - - // Not an error as user may not yet have authenticated. - return null; - } - - writeTokenToStorage(token: string): void { - localStorage.setItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY, token); - } - - removeTokenFromStorage(): void { - localStorage.removeItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY); - } - - private getTokenFromStorage(): string { - return localStorage.getItem(this.DIGITALOCEAN_TOKEN_STORAGE_KEY); - } -} diff --git a/src/server_manager/web_app/digitalocean_server.ts b/src/server_manager/web_app/digitalocean_server.ts index e7facdfa..856a9b60 100644 --- a/src/server_manager/web_app/digitalocean_server.ts +++ b/src/server_manager/web_app/digitalocean_server.ts @@ -15,22 +15,14 @@ import {EventEmitter} from 'eventemitter3'; import {DigitalOceanSession, DropletInfo} from '../cloud/digitalocean_api'; -import * as crypto from '../infrastructure/crypto'; import * as errors from '../infrastructure/errors'; import {asciiToHex, hexToString} from '../infrastructure/hex_encoding'; -import * as do_install_script from '../install_scripts/do_install_script'; import * as server from '../model/server'; import {ShadowboxServer} from './shadowbox_server'; -// WARNING: these strings must be lowercase due to a DigitalOcean case -// sensitivity bug. - -// Tag used to mark Shadowbox Droplets. -const SHADOWBOX_TAG = 'shadowbox'; // Prefix used in key-value tags. const KEY_VALUE_TAG = 'kv'; - // The tag key for the manager API certificate fingerprint. const CERTIFICATE_FINGERPRINT_TAG = 'certsha256'; // The tag key for the manager API URL. @@ -64,7 +56,7 @@ enum InstallState { DELETED } -class DigitaloceanServer extends ShadowboxServer implements server.ManagedServer { +export class DigitalOceanServer extends ShadowboxServer implements server.ManagedServer { private eventQueue = new EventEmitter(); private installState: InstallState = InstallState.UNKNOWN; @@ -327,107 +319,3 @@ function startsWithCaseInsensitive(text: string, prefix: string) { export function GetCityId(slug: server.RegionId): string { return slug.substr(0, 3).toLowerCase(); } - -const MACHINE_SIZE = 's-1vcpu-1gb'; - -export class DigitaloceanServerRepository implements server.ManagedServerRepository { - private servers: DigitaloceanServer[] = []; - - constructor( - private digitalOcean: DigitalOceanSession, private image: string, private metricsUrl: string, - private sentryApiUrl: string|undefined, private debugMode: boolean) {} - - // Return a map of regions that are available and support our target machine size. - getRegionMap(): Promise> { - return this.digitalOcean.getRegionInfo().then((regions) => { - const ret: server.RegionMap = {}; - regions.forEach((region) => { - const cityId = GetCityId(region.slug); - if (!(cityId in ret)) { - ret[cityId] = []; - } - if (region.available && region.sizes.indexOf(MACHINE_SIZE) !== -1) { - ret[cityId].push(region.slug); - } - }); - return ret; - }); - } - - // Creates a server and returning it when it becomes active. - createServer(region: server.RegionId, name: string): Promise { - console.time('activeServer'); - console.time('servingServer'); - const onceKeyPair = crypto.generateKeyPair(); - const watchtowerRefreshSeconds = this.image ? 30 : undefined; - const installCommand = getInstallScript( - this.digitalOcean.accessToken, name, this.image, watchtowerRefreshSeconds, this.metricsUrl, - this.sentryApiUrl); - - const dropletSpec = { - installCommand, - size: MACHINE_SIZE, - image: 'docker-18-04', - tags: [SHADOWBOX_TAG], - }; - return onceKeyPair - .then((keyPair) => { - if (this.debugMode) { - // Strip carriage returns, which produce weird blank lines when pasted into a terminal. - console.debug( - `private key for SSH access to new droplet:\n${ - keyPair.private.replace(/\r/g, '')}\n\n` + - 'Use "ssh -i keyfile root@[ip_address]" to connect to the machine'); - } - return this.digitalOcean.createDroplet(name, region, keyPair.public, dropletSpec); - }) - .then((response) => { - return this.createDigitalOceanServer(this.digitalOcean, response.droplet); - }); - } - - listServers(fetchFromHost = true): Promise { - if (!fetchFromHost) { - return Promise.resolve(this.servers); // Return the in-memory servers. - } - return this.digitalOcean.getDropletsByTag(SHADOWBOX_TAG).then((droplets) => { - this.servers = []; - return droplets.map((droplet) => { - return this.createDigitalOceanServer(this.digitalOcean, droplet); - }); - }); - } - - // Creates a DigitaloceanServer object and adds it to the in-memory server list. - private createDigitalOceanServer(digitalOcean: DigitalOceanSession, dropletInfo: DropletInfo) { - const server = new DigitaloceanServer(digitalOcean, dropletInfo); - this.servers.push(server); - return server; - } -} - -function sanitizeDigitaloceanToken(input: string): string { - const sanitizedInput = input.trim(); - const pattern = /^[A-Za-z0-9_\/-]+$/; - if (!pattern.test(sanitizedInput)) { - throw new Error('Invalid DigitalOcean Token'); - } - return sanitizedInput; -} - -// cloudFunctions needs to define cloud::public_ip and cloud::add_tag. -function getInstallScript( - accessToken: string, name: string, image?: string, watchtowerRefreshSeconds?: number, - metricsUrl?: string, sentryApiUrl?: string): string { - const sanitizedAccessToken = sanitizeDigitaloceanToken(accessToken); - // TODO: consider shell escaping these variables. - return '#!/bin/bash -eu\n' + - `export DO_ACCESS_TOKEN=${sanitizedAccessToken}\n` + - (image ? `export SB_IMAGE=${image}\n` : '') + - (watchtowerRefreshSeconds ? - `export WATCHTOWER_REFRESH_SECONDS=${watchtowerRefreshSeconds}\n` : - '') + - (sentryApiUrl ? `export SENTRY_API_URL="${sentryApiUrl}"\n` : '') + - (metricsUrl ? `export SB_METRICS_URL=${metricsUrl}\n` : '') + - `export SB_DEFAULT_SERVER_NAME="${name}"\n` + do_install_script.SCRIPT; -} diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts index df46bf09..4b90237f 100644 --- a/src/server_manager/web_app/main.ts +++ b/src/server_manager/web_app/main.ts @@ -16,11 +16,10 @@ import './ui_components/app-root.js'; import * as digitalocean_api from '../cloud/digitalocean_api'; import * as i18n from '../infrastructure/i18n'; -import {getSentryApiUrl} from '../infrastructure/sentry'; import {App} from './app'; -import {DigitalOceanTokenManager} from './digitalocean_oauth'; -import * as digitalocean_server from './digitalocean_server'; +import {CloudAccounts} from './cloud_accounts'; +import {DigitalOceanAccount} from './digitalocean_account'; import {ManualServerRepository} from './manual_server'; import {AppRoot} from './ui_components/app-root.js'; @@ -97,16 +96,22 @@ document.addEventListener('WebComponentsReady', () => { // Parse URL query params. const params = new URL(document.URL).searchParams; const debugMode = params.get('outlineDebugMode') === 'true'; - const metricsUrl = params.get('metricsUrl'); - const shadowboxImage = params.get('image'); const version = params.get('version'); - const sentryDsn = params.get('sentryDsn'); + + const shadowboxImageId = params.get('image'); + const shadowboxSettings = { + imageId: shadowboxImageId, + metricsUrl: params.get('metricsUrl'), + sentryApiUrl: params.get('sentryDsn'), + watchtowerRefreshSeconds: shadowboxImageId ? 30 : undefined, + }; // Set DigitalOcean server repository parameters. - const digitalOceanServerRepositoryFactory = (session: digitalocean_api.DigitalOceanSession) => { - return new digitalocean_server.DigitaloceanServerRepository( - session, shadowboxImage, metricsUrl, getSentryApiUrl(sentryDsn), debugMode); + const digitalOceanAccountFactory = (accessToken: string) => { + const session = new digitalocean_api.RestApiSession(accessToken); + return new DigitalOceanAccount(session, shadowboxSettings, debugMode); }; + const cloudAccounts = new CloudAccounts(digitalOceanAccountFactory); // Create and start the app. const language = getLanguageToUse(); @@ -120,10 +125,5 @@ document.addEventListener('WebComponentsReady', () => { const filteredLanguageDefs = Object.values(SUPPORTED_LANGUAGES); appRoot.supportedLanguages = sortLanguageDefsByName(filteredLanguageDefs); appRoot.setLanguage(language.string(), languageDirection); - new App( - appRoot, version, digitalocean_api.createDigitalOceanSession, - digitalOceanServerRepositoryFactory, new ManualServerRepository('manualServers'), - new DigitalOceanTokenManager()) - .start(); + new App(appRoot, version, new ManualServerRepository('manualServers'), cloudAccounts).start(); }); -