Repurposes TokenManager as CloudAccounts (#827)

* gitignore

* Converts ManagedServerRepository into Account

* Adds CloudAccounts

* Moves token manager to cloud accounts

* Auto-format

* Newline

* Oops wrong year

* Addresses review commetns

* Addresses review comments

* Addresses review comments

* Addresses review commetns
This commit is contained in:
mpmcroy 2021-02-19 16:30:54 -05:00 committed by GitHub
parent eb5ac5eeb4
commit c36afbcbbb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 342 additions and 345 deletions

View file

@ -89,11 +89,7 @@ export interface DigitalOceanSession {
getDroplets(): Promise<DropletInfo[]>;
}
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) {}

View file

@ -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<string>;
// Returns the status of the account.
getStatus(): Promise<Status>;
// 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<ManagedServer[]>;
// Return a map of regions that are available and support our target machine size.
getRegionMap(): Promise<Readonly<RegionMap>>;
// 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<ManagedServer>;
}

View file

@ -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<ManagedServer[]>;
// Return a map of regions that are available and support our target machine size.
getRegionMap(): Promise<Readonly<RegionMap>>;
// 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<ManagedServer>;
}
// Configuration for manual servers. This is the output emitted from the
// shadowbox install script, which is needed for the manager connect to
// shadowbox.

View file

@ -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<string> {
return 'name';
}
async getStatus(): Promise<digitalocean.Status> {
return digitalocean.Status.ACTIVE;
}
listServers() {
return Promise.resolve(this.servers);
}

View file

@ -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<string, server.Server>();
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<void> {
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<server.ManagedServer[]> {
if (!accessToken) {
private async loadDigitalOceanServers(digitalOceanAccount: digitalocean.Account):
Promise<server.ManagedServer[]> {
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<void> {
const doSession = this.createDigitalOceanSession(accessToken);
private async ensureActiveDigitalOceanAccount(digitalOceanAccount: digitalocean.Account):
Promise<void> {
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<void> {
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<void> {
private async showDigitalOceanCreateServer(digitalOceanAccount: digitalocean.Account):
Promise<void> {
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);

View file

@ -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);
}
}

View file

@ -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<string> {
return (await this.digitalOcean.getAccount())?.email;
}
async getStatus(): Promise<digitalocean.Status> {
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<Readonly<digitalocean.RegionMap>> {
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<server.ManagedServer> {
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<server.ManagedServer[]> {
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;
}

View file

@ -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);
}
}

View file

@ -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<Readonly<server.RegionMap>> {
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<server.ManagedServer> {
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<server.ManagedServer[]> {
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;
}

View file

@ -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();
});