Adds GCP create server flow (#864)

* gitignore

* Adds GCP token to CloudAccounts

* Checkpoint

* Checkpoint

* Refactors cloud accounts

* Addresses comments

* Auto-formatting

* Reverts gallery webpack GCP local server

* Reverts webpack config change

* Minor cleanup

* Removes unused class

* Reverts initial feature flag implementation

* Removes unneeded logging

* Adds credentials getters to CloudAccounts and updates tests

* Auto-format

* Adds FakeCloudAccounts

* Removes credentialsGetter from CloudAccounts

* Removes account factories from CloudAccounts

* Addresses review comments

* Addresses review commetns

* Auto-formatting

* Adds GcpAccount implementation

* Auto-format

* Auto-format

* Replace OAuth config client id

* Adds method to fetch OpenID userinfo

* Adds method to fetch OpenID userinfo

* Checkpoint

* Auto-format

* Adds method to refresh GCP access token

* Uncommenting

* Checkpoint

Auto-formatting

Updates intro step with gcp account

Auto-format

typo

* Add comment

* fetch refactor

* Auto-format

* Remove account from CloudAccounts

* Refactors refresh token exchange

* Auto-format

* Auto-format

* Initial GCP create server UI

* Auto-format

* Addresses review comments

* URL encoding

* Encode URL

* Adds GCP strings

* Addresses review comments

* Addresses review comments

* Addresses review comments

* Adds GCP create server app to gallery; Addresses review comments;

* Auto-formatting

* Addresses review comments

* Auto-format

* Addresses review comments

* Auto-format

* Addresses review comments

* Addresses review comments

* Auto-format

* Addresses review comment

* Auto-format

* Addresses review comments

* Minor fix

* Removes comments

* Addresses review comments

* Auto-format

* Addresses review comments
This commit is contained in:
mpmcroy 2021-04-09 14:15:00 -04:00 committed by GitHub
parent 2c575bb8a5
commit b7cda5c7ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1844 additions and 64 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
/build
node_modules/
/src/server_manager/install_scripts/do_install_script.ts
/src/server_manager/install_scripts/gcp_install_script.ts
yarn-error.log
.vscode/
.idea/

View file

@ -14,7 +14,7 @@
"node": "^12"
},
"scripts": {
"clean": "rm -rf src/*/node_modules/ build/ node_modules/ src/server_manager/install_scripts/do_install_script.ts third_party/shellcheck/download/",
"clean": "rm -rf src/*/node_modules/ build/ node_modules/ src/server_manager/install_scripts/do_install_script.ts src/server_manager/install_scripts/gcp_install_script.ts third_party/shellcheck/download/",
"do": "bash ./scripts/do_action.sh",
"lint": "yarn shellcheck && yarn tslint",
"shellcheck": "bash ./scripts/shellcheck.sh",

View file

@ -0,0 +1,577 @@
// 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.
// TODO: Share the same OAuth config between electron app and renderer.
// Keep this in sync with {@link gcp_oauth.ts#OAUTH_CONFIG}
const GCP_OAUTH_CLIENT_ID =
'946220775492-osi1dm2rhhpo4upm6qqfv9fiivv1qu6c.apps.googleusercontent.com';
/** @see https://cloud.google.com/compute/docs/reference/rest/v1/instances */
export type Instance = Readonly<{
id: string; creationTimestamp: string; name: string; description: string;
tags: {items: string[]; fingerprint: string;};
machineType: string;
zone: string;
networkInterfaces: Array<{
network: string; subnetwork: string; networkIP: string; ipv6Address: string; name: string;
accessConfigs: Array<{
type: string; name: string; natIP: string; setPublicPtr: boolean; publicPtrDomainName: string;
networkTier: string;
kind: string;
}>;
}>;
}>;
/**
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/getGuestAttributes#response-body
*/
type GuestAttributes = Readonly<{
variableKey: string; variableValue: string; queryPath: string;
queryValue: {items: Array<{namespace: string; key: string; value: string;}>;};
}>;
/** @see https://cloud.google.com/compute/docs/reference/rest/v1/zones */
type Zone = Readonly<{
id: string; creationTimestamp: string; name: string; description: string; status: 'UP' | 'DOWN';
region: string;
}>;
type Status = Readonly<{code: number; message: string}>;
/** @see https://cloud.google.com/resource-manager/reference/rest/Shared.Types/Operation */
export type ResourceManagerOperation = Readonly<{name: string; done: boolean; error: Status;}>;
/**
* @see https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations
* @see https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations
*/
type ComputeEngineOperation = Readonly<
{id: string; name: string; targetId: string; status: string; error: {errors: Status[]}}>;
/**
* @see https://cloud.google.com/service-usage/docs/reference/rest/Shared.Types/ListOperationsResponse#Operation
*/
type ServiceUsageOperation = Readonly<{name: string; done: boolean; error: Status;}>;
/** @see https://cloud.google.com/resource-manager/reference/rest/v1/projects */
export type Project =
Readonly<{projectNumber: string; projectId: string; name: string, lifecycleState: string;}>;
/** @see https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/get#response-body */
type Firewall = Readonly<{id: string; name: string;}>;
/** https://cloud.google.com/billing/docs/reference/rest/v1/billingAccounts */
export type BillingAccount =
Readonly<{name: string; open: boolean; displayName: string; masterBillingAccount: string;}>;
/** https://cloud.google.com/billing/docs/reference/rest/v1/ProjectBillingInfo */
export type ProjectBillingInfo = Readonly<
{name: string; projectId: string; billingAccountName?: string; billingEnabled?: boolean;}>;
/**
* @see https://accounts.google.com/.well-known/openid-configuration for
* supported claims.
*
* Note: The supported claims are optional and not guaranteed to be in the
* response.
*/
export type UserInfo = Readonly<{email: string;}>;
type Service = Readonly<
{name: string; config: {name: string;}; state: 'STATE_UNSPECIFIED' | 'DISABLED' | 'ENABLED';}>;
type ListInstancesResponse = Readonly<{items: Instance[]; nextPageToken: string;}>;
type ListZonesResponse = Readonly<{items: Zone[]; nextPageToken: string;}>;
type ListProjectsResponse = Readonly<{projects: Project[]; nextPageToken: string;}>;
type ListFirewallsResponse = Readonly<{items: Firewall[]; nextPageToken: string;}>;
type ListBillingAccountsResponse =
Readonly<{billingAccounts: BillingAccount[]; nextPageToken: string}>;
type ListEnabledServicesResponse = Readonly<{services: Service[]; nextPageToken: string;}>;
type RefreshAccessTokenResponse = Readonly<{access_token: string; expires_in: number;}>;
export class HttpError extends Error {
constructor(private statusCode: number, message?: string) {
super(message);
}
getStatusCode(): number {
return this.statusCode;
}
}
export class RestApiClient {
private readonly GCP_HEADERS = new Map<string, string>([
['Content-type', 'application/json'],
['Accept', 'application/json'],
]);
private accessToken: string;
constructor(private refreshToken: string) {}
/**
* Creates a new Google Compute Engine VM instance in a specified GCP project.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/insert
*
* @param projectId - The GCP project ID.
* @param zoneId - The zone in which to create the instance.
* @param data - Request body data. See documentation.
*/
async createInstance(projectId: string, zoneId: string, data: {}): Promise<ComputeEngineOperation> {
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
'POST',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
zoneId}/instances`),
this.GCP_HEADERS, null, data);
return await this.computeEngineOperationZoneWait(projectId, zoneId, operation.name);
}
/**
* Deletes a specified Google Compute Engine VM instance.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/delete
*
* @param projectId - The GCP project ID.
* @param instanceId - The ID of the instance to delete.
* @param zoneId - The zone in which the instance resides.
*/
async deleteInstance(projectId: string, instanceId: string, zoneId: string): Promise<void> {
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
'DELETE',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
zoneId}/instances/${instanceId}`),
this.GCP_HEADERS);
await this.computeEngineOperationZoneWait(projectId, zoneId, operation.name);
}
/**
* Gets the specified Google Compute Engine VM instance resource.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/get
*
* @param projectId - The GCP project ID.
* @param instanceId - The ID of the instance to retrieve.
* @param zoneId - The zone in which the instance resides.
*/
getInstance(projectId: string, instanceId: string, zoneId: string): Promise<Instance> {
return this.fetchAuthenticated(
'GET',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
zoneId}/instances/${instanceId}`),
this.GCP_HEADERS);
}
/**
* Lists the Google Compute Engine VM instances in a specified zone.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/list
*
* @param projectId - The GCP project ID.
* @param zoneId - The zone to query.
* @param filter - See documentation.
*/
// TODO: Pagination
listInstances(projectId: string, zoneId: string, filter?: string):
Promise<ListInstancesResponse> {
let parameters = null;
if (filter) {
parameters = new Map<string, string>([
['filter', filter],
]);
}
return this.fetchAuthenticated(
'GET',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
zoneId}/instances`),
this.GCP_HEADERS, parameters);
}
/**
* Creates a static IP address.
*
* If no IP address is provided, a new static IP address is created. If an
* ephemeral IP address is provided, it is promoted to a static IP address.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/addresses/insert
*
* @param projectId - The GCP project ID.
* @param regionId - The GCP region.
* @param data - Request body data. See documentation.
*/
async createStaticIp(projectId: string, regionId: string, data: {}): Promise<ComputeEngineOperation> {
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
'POST',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/regions/${
regionId}/addresses`),
this.GCP_HEADERS, null, data);
return await this.computeEngineOperationRegionWait(projectId, regionId, operation.name);
}
/**
* Deletes a static IP address.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/addresses/delete
*
* @param projectId - The GCP project ID.
* @param addressName - The name of the static IP address resource.
* @param regionId - The GCP region of the resource.
*/
async deleteStaticIp(projectId: string, addressName: string, regionId: string): Promise<void> {
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
'DELETE',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/regions/${
regionId}/addresses/${addressName}`),
this.GCP_HEADERS);
await this.computeEngineOperationRegionWait(projectId, regionId, operation.name);
}
/**
* Lists the guest attributes applied to the specified Google Compute Engine VM instance.
*
* @see https://cloud.google.com/compute/docs/storing-retrieving-metadata#guest_attributes
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/getGuestAttributes
*
* @param projectId - The GCP project ID.
* @param instanceId - The ID of the VM instance.
* @param zoneId - The zone in which the instance resides.
* @param namespace - The namespace of the guest attributes.
*/
async getGuestAttributes(
projectId: string, instanceId: string, zoneId: string,
namespace: string): Promise<GuestAttributes|undefined> {
try {
const parameters = new Map<string, string>([['queryPath', namespace]]);
// We must await the call to getGuestAttributes to properly catch any exceptions.
return await this.fetchAuthenticated(
'GET',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
zoneId}/instances/${instanceId}/getGuestAttributes`),
this.GCP_HEADERS, parameters);
} catch (error) {
// TODO: Distinguish between 404 not found and other errors.
return undefined;
}
}
/**
* Creates a firewall under the specified GCP project.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/insert
*
* @param projectId - The GCP project ID.
* @param data - Request body data. See documentation.
*/
async createFirewall(projectId: string, data: {}): Promise<ComputeEngineOperation> {
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
'POST',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/global/firewalls`),
this.GCP_HEADERS, null, data);
return await this.computeEngineOperationGlobalWait(projectId, operation.name);
}
/**
* @param projectId - The GCP project ID.
* @param name - The firewall name.
*/
// TODO: Replace with getFirewall (and handle 404 NotFound)
listFirewalls(projectId: string, name: string): Promise<ListFirewallsResponse> {
const filter = `name=${name}`;
const parameters = new Map<string, string>([['filter', filter]]);
return this.fetchAuthenticated(
'GET',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/global/firewalls`),
this.GCP_HEADERS, parameters);
}
/**
* Lists the zones available to a given GCP project.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/zones/list
*
* @param projectId - The GCP project ID.
*/
// TODO: Pagination
listZones(projectId: string): Promise<ListZonesResponse> {
return this.fetchAuthenticated(
'GET', new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones`),
this.GCP_HEADERS);
}
/**
* Lists all services that have been enabled on the project.
*
* @param projectId - The GCP project ID.
*/
listEnabledServices(projectId: string): Promise<ListEnabledServicesResponse> {
const parameters = new Map<string, string>([['filter', 'state:ENABLED']]);
return this.fetchAuthenticated(
'GET', new URL(`https://serviceusage.googleapis.com/v1/projects/${projectId}/services`),
this.GCP_HEADERS, parameters);
}
/**
* @param projectId - The GCP project ID.
* @param data - Request body data. See documentation.
*/
enableServices(projectId: string, data: {}): Promise<ServiceUsageOperation> {
return this.fetchAuthenticated(
'POST',
new URL(
`https://serviceusage.googleapis.com/v1/projects/${projectId}/services:batchEnable`),
this.GCP_HEADERS, null, data);
}
/**
* Creates a new GCP project
*
* The project ID must conform to the following:
* - must be 6 to 30 lowercase letters, digits, or hyphens
* - must start with a letter
* - no trailing hyphens
*
* @see https://cloud.google.com/resource-manager/reference/rest/v1/projects/create
*
* @param projectId - The unique user-assigned project ID.
* @param data - Request body data. See documentation.
*/
createProject(projectId: string, data: {}): Promise<ResourceManagerOperation> {
return this.fetchAuthenticated(
'POST', new URL('https://cloudresourcemanager.googleapis.com/v1/projects'),
this.GCP_HEADERS, null, data);
}
/**
* Lists the GCP projects that the user has access to.
*
* @see https://cloud.google.com/resource-manager/reference/rest/v1/projects/list
*
* @param filter - See documentation.
*/
listProjects(filter?: string): Promise<ListProjectsResponse> {
let parameters = null;
if (filter) {
parameters = new Map<string, string>([
['filter', filter],
]);
}
return this.fetchAuthenticated(
'GET', new URL('https://cloudresourcemanager.googleapis.com/v1/projects'), this.GCP_HEADERS,
parameters);
}
/**
* Gets the billing information for a specified GCP project.
*
* @see https://cloud.google.com/billing/docs/reference/rest/v1/projects/getBillingInfo
*
* @param projectId - The GCP project ID.
*/
getProjectBillingInfo(projectId: string): Promise<ProjectBillingInfo> {
return this.fetchAuthenticated(
'GET', new URL(`https://cloudbilling.googleapis.com/v1/projects/${projectId}/billingInfo`),
this.GCP_HEADERS);
}
/**
* Associates a GCP project with a billing account.
*
* @see https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
*
* @param projectId - The GCP project ID.
* @param data - Request body data. See documentation.
*/
updateProjectBillingInfo(projectId: string, data: {}): Promise<ProjectBillingInfo> {
return this.fetchAuthenticated(
'PUT', new URL(`https://cloudbilling.googleapis.com/v1/projects/${projectId}/billingInfo`),
this.GCP_HEADERS, null, data);
}
/**
* Lists the billing accounts that the user has access to.
*
* @see https://cloud.google.com/billing/docs/reference/rest/v1/billingAccounts/list
*/
listBillingAccounts(): Promise<ListBillingAccountsResponse> {
return this.fetchAuthenticated(
'GET', new URL(`https://cloudbilling.googleapis.com/v1/billingAccounts`), this.GCP_HEADERS);
}
/**
* Waits for a specified Google Compute Engine zone operation to complete.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations/wait
*
* @param projectId - The GCP project ID.
* @param zoneId - The zone ID.
* @param operationId - The operation ID.
*/
computeEngineOperationZoneWait(projectId: string, zoneId: string, operationId: string):
Promise<ComputeEngineOperation> {
return this.fetchAuthenticated(
'POST',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
zoneId}/operations/${operationId}/wait`),
this.GCP_HEADERS);
}
/**
* Waits for a specified Google Compute Engine region operation to complete.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/regionOperations/wait
*
* @param projectId - The GCP project ID.
* @param regionId - The region ID.
* @param operationId - The operation ID.
*/
computeEngineOperationRegionWait(projectId: string, regionId: string, operationId: string):
Promise<ComputeEngineOperation> {
return this.fetchAuthenticated(
'POST',
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/regions/${
regionId}/operations/${operationId}/wait`),
this.GCP_HEADERS);
}
/**
* Waits for a specified Google Compute Engine global operation to complete.
*
* @see https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations/wait
*
* @param projectId - The GCP project ID.
* @param operationId - The operation ID.
*/
computeEngineOperationGlobalWait(projectId: string, operationId: string):
Promise<ComputeEngineOperation> {
return this.fetchAuthenticated(
'POST',
new URL(`https://compute.googleapis.com/compute/v1/projects/${
projectId}/global/operations/${operationId}/wait`),
this.GCP_HEADERS);
}
resourceManagerOperationGet(operationId: string): Promise<ResourceManagerOperation> {
return this.fetchAuthenticated(
'GET', new URL(`https://cloudresourcemanager.googleapis.com/v1/${operationId}`),
this.GCP_HEADERS);
}
serviceUsageOperationGet(operationId: string): Promise<ServiceUsageOperation> {
return this.fetchAuthenticated(
'GET', new URL(`https://serviceusage.googleapis.com/v1/${operationId}`), this.GCP_HEADERS);
}
/**
* Gets the OpenID Connect profile information.
*
* For a list of the supported Google OpenID claims
* @see https://accounts.google.com/.well-known/openid-configuration.
*
* The OpenID standard, including the "userinfo" response and core claims, is
* defined in the links below:
* @see https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
* @see https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
*/
getUserInfo(): Promise<UserInfo> {
const parameters = new Map<string, string>([['access_token', this.accessToken]]);
return this.fetchAuthenticated(
'POST', new URL('https://openidconnect.googleapis.com/v1/userinfo'), this.GCP_HEADERS);
}
private async refreshGcpAccessToken(refreshToken: string): Promise<string> {
const headers = new Map<string, string>(
[['Host', 'oauth2.googleapis.com'], ['Content-Type', 'application/x-www-form-urlencoded']]);
const data = {
// TODO: Consider moving client ID to the caller.
client_id: GCP_OAUTH_CLIENT_ID,
refresh_token: refreshToken,
grant_type: 'refresh_token',
};
const encodedData = this.encodeFormData(data);
const response: RefreshAccessTokenResponse = await this.fetchUnauthenticated(
'POST', new URL('https://oauth2.googleapis.com/token'), headers, null, encodedData);
return response.access_token;
}
/**
* Revokes a token.
*
* @see https://developers.google.com/identity/protocols/oauth2/native-app
*
* @param token - A refresh token or access token
*/
private async revokeGcpToken(token: string): Promise<void> {
const headers = new Map<string, string>(
[['Host', 'oauth2.googleapis.com'], ['Content-Type', 'application/x-www-form-urlencoded']]);
const parameters = new Map<string, string>([['token', token]]);
return this.fetchUnauthenticated(
'GET', new URL('https://oauth2.googleapis.com/revoke'), headers, parameters);
}
// tslint:disable-next-line:no-any
private async fetchAuthenticated<T>(method: string, url: URL, headers: Map<string, string>, parameters?: Map<string, string>, data?: any): Promise<T> {
const httpHeaders = new Map(headers);
// TODO: Handle token expiration/revokation.
if (!this.accessToken) {
this.accessToken = await this.refreshGcpAccessToken(this.refreshToken);
}
httpHeaders.set('Authorization', `Bearer ${this.accessToken}`);
return this.fetchUnauthenticated(method, url, httpHeaders, parameters, data);
}
// tslint:disable-next-line:no-any
private async fetchUnauthenticated<T>(method: string, url: URL, headers: Map<string, string>, parameters?: Map<string, string>, data?: any): Promise<T> {
const customHeaders = new Headers();
headers.forEach((value, key) => {
customHeaders.append(key, value);
});
if (parameters) {
parameters.forEach((value: string, key: string) => url.searchParams.append(key, value));
}
// TODO: More robust handling of data types
if (typeof data === 'object') {
data = JSON.stringify(data);
}
const response = await fetch(url.toString(), {
method: method.toUpperCase(),
headers: customHeaders,
...(data && {body: data}),
});
if (!response.ok) {
throw new HttpError(response.status, response.statusText);
}
try {
let result = undefined;
if (response.status !== 204) {
result = await response.json();
}
return result;
} catch (e) {
throw new Error('Error parsing response body: ' + JSON.stringify(e));
}
}
private encodeFormData(data: object): string {
return Object.entries(data)
.map(entry => {
return encodeURIComponent(entry[0]) + '=' + encodeURIComponent(entry[1]);
})
.join('&');
}
}

View file

@ -22,7 +22,11 @@ const OAUTH_CONFIG = {
client_id: '946220775492-osi1dm2rhhpo4upm6qqfv9fiivv1qu6c.apps.googleusercontent.com',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/compute',
'https://www.googleapis.com/auth/cloudplatformprojects',
'https://www.googleapis.com/auth/cloud-billing',
'https://www.googleapis.com/auth/service.management',
'https://www.googleapis.com/auth/cloud-platform.read-only',
],
};
const REDIRECT_PATH = '/gcp/oauth/callback';

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,29 @@
// 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.
'use strict';
const fs = require('fs');
const path = require('path');
const tarballBinary = fs.readFileSync(process.argv[2]);
const base64Tarball = new Buffer(tarballBinary).toString('base64');
const scriptText = `
(base64 --decode | tar --extract --gzip ) <<EOM
${base64Tarball}
EOM
./gcp_install_server.sh
`;
console.log(`export const SCRIPT = ${JSON.stringify(scriptText)};`);

View file

@ -0,0 +1,124 @@
#!/bin/bash
#
# 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.
# Script to install Shadowbox on a GCP Compute Engine instance
# You may set the following environment variables, overriding their defaults:
# SB_IMAGE: Shadowbox Docker image to install, e.g. quay.io/outline/shadowbox:nightly
# SB_API_PORT: The port number of the management API.
# SENTRY_API_URL: Url to post Sentry report to on error.
# WATCHTOWER_REFRESH_SECONDS: refresh interval in seconds to check for updates,
# defaults to 3600.
set -euo pipefail
export SHADOWBOX_DIR="${SHADOWBOX_DIR:-${HOME:-/root}/shadowbox}"
mkdir -p "${SHADOWBOX_DIR}"
# Save output for debugging
exec &> "${SHADOWBOX_DIR}/install-shadowbox-output"
function cloud::public_ip() {
curl curl -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip"
}
# Initialize sentry log file.
export SENTRY_LOG_FILE="${SHADOWBOX_DIR}/sentry-log-file.txt"
true > "${SENTRY_LOG_FILE}"
function log_for_sentry() {
echo "[$(date "+%Y-%m-%d@%H:%M:%S")]" "gcp_install_server.sh" "$@" >> "${SENTRY_LOG_FILE}"
}
function post_sentry_report() {
if [[ -n "${SENTRY_API_URL}" ]]; then
# Get JSON formatted string. This command replaces newlines with literal '\n'
# but otherwise assumes that there are no other characters to escape for JSON.
# If we need better escaping, we can install the jq command line tool.
local -ir SENTRY_PAYLOAD_BYTE_LIMIT=8000
local SENTRY_PAYLOAD
SENTRY_PAYLOAD="{\"message\": \"Install error:\n$(awk '{printf "%s\\n", $0}' < "${SENTRY_LOG_FILE}" | tail --bytes "${SENTRY_PAYLOAD_BYTE_LIMIT}")\"}"
# See Sentry documentation at:
# https://media.readthedocs.org/pdf/sentry/7.1.0/sentry.pdf
curl "${SENTRY_API_URL}" -H "Origin: shadowbox" --data-binary "${SENTRY_PAYLOAD}"
fi
}
# Applies a guest attribute to the GCE VM.
function cloud::set_guest_attribute() {
local label_key="$1"
local label_value="$2"
local GUEST_ATTIBUTE_NAMESPACE="outline"
local SET_GUEST_ATTRIBUTE_URL="http://metadata.google.internal/computeMetadata/v1/instance/guest-attributes/${GUEST_ATTIBUTE_NAMESPACE}/${label_key}"
curl -H "Metadata-Flavor: Google" -X PUT -d "${label_value}" "${SET_GUEST_ATTRIBUTE_URL}"
}
# Enable BBR.
# Recent DigitalOcean one-click images are based on Ubuntu 18 and have kernel 4.15+.
log_for_sentry "Enabling BBR"
cat >> /etc/sysctl.conf << EOF
# Added by Outline.
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
EOF
sysctl -p
log_for_sentry "Initializing ACCESS_CONFIG"
export ACCESS_CONFIG="${SHADOWBOX_DIR}/access.txt"
true > "${ACCESS_CONFIG}"
# Set trap which publishes an error tag and sentry report only if there is an error.
function finish {
INSTALL_SERVER_EXIT_CODE=$?
log_for_sentry "In EXIT trap, exit code ${INSTALL_SERVER_EXIT_CODE}"
if ! ( grep --quiet apiUrl "${ACCESS_CONFIG}" && grep --quiet certSha256 "${ACCESS_CONFIG}" ); then
echo "INSTALL_SCRIPT_FAILED: ${INSTALL_SERVER_EXIT_CODE}" | cloud::set_guest_attribute "install-error" "true"
# Post error report to sentry.
post_sentry_report
fi
}
trap finish EXIT
# Run install script asynchronously, so tags can be written as soon as they are ready.
log_for_sentry "Running install_server.sh"
./install_server.sh&
declare -ir install_pid=$!
# Save tags for access information.
log_for_sentry "Reading tags from ACCESS_CONFIG"
tail -f "${ACCESS_CONFIG}" "--pid=${install_pid}" | while IFS=: read -r key value; do
case "${key}" in
certSha256)
log_for_sentry "Writing certSha256 tag"
echo "case certSha256: ${key}/${value}"
# The value is hex(fingerprint) and Electron expects base64(fingerprint).
hex_fingerprint="${value}"
base64_fingerprint="$(echo -n "${hex_fingerprint}" | xxd -revert -p -c 255 | base64)"
cloud::set_guest_attribute "${key}" "${base64_fingerprint}"
;;
apiUrl)
log_for_sentry "Writing apiUrl tag"
echo "case apiUrl: ${key}/${value}"
url_value=$(echo -n "${value}")
cloud::set_guest_attribute "${key}" "${url_value}"
;;
esac
done
# Wait for install script to finish, so that if there is any error in install_server.sh,
# the finish trap in this file will be able to access its error code.
wait "${install_pid}"

View file

@ -94,6 +94,8 @@
"gcp-create-project": "Create a Google Cloud project",
"gcp-create-server": "Create your server",
"gcp-create-vm": "Create a VM instance",
"gcp-disconnect": "Disconnect",
"gcp-disconnect-account": "Disconnect Google Cloud Platform account",
"gcp-firewall-create-0": "{openLink}Add a new firewall rule{closeLink} to your Compute Engine project.",
"gcp-firewall-create-1": "Type 'outline' in the 'Name' field.",
"gcp-firewall-create-2": "Type 'outline' in the 'Target tags' field.",
@ -187,6 +189,7 @@
"server-usage": "Usage (last 30 days)",
"servers-add": "Add server",
"servers-digitalocean": "DigitalOcean servers",
"servers-gcp": "Google Cloud Platform servers",
"servers-manual": "Servers",
"settings-access-key-port": "Port for new access keys",
"settings-metrics-header": "Share anonymous metrics",

View file

@ -483,6 +483,14 @@
"message": "Create a VM Instance",
"description": "This string appears as a header for a set of instructions for creating a new Google Cloud VM"
},
"gcp_disconnect": {
"message": "Disconnect",
"description": "This string appears as a button in a dialog to disconnect the user's Google Cloud Platform account from the application. Clicking it signs the user out of Google Cloud Platform, a cloud server provider."
},
"gcp_disconnect_account": {
"message": "Disconnect Google Cloud Platform account",
"description": "This string appears as a header in a dialog to disconnect the user's Google Cloud Platform account from the application. Google Cloud Platform is a cloud server provider name and should not be translated."
},
"gcp_firewall_create_0": {
"message": "$START_OF_LINK$Add a new firewall rule$END_OF_LINK$ to your Compute Engine project.",
"description": "This string appears within the server setup view as an item of a list that provides instructions to configure a firewall in Google Cloud Platform. Compute Engine is a product of Google Cloud Platform and should not be translated.",
@ -887,6 +895,10 @@
"message": "DigitalOcean servers",
"description": "This string appears in an application drawer as the header of a section that displays the list of DigitalOcean servers. DigitalOcean is a cloud server provider name and should not be translated."
},
"servers_gcp": {
"message": "Google Cloud Platform servers",
"description": "This string appears in an application drawer as the header of a section that displays the list of Google Cloud Platform servers. Google Cloud Platform is a cloud server provider name and should not be translated."
},
"servers_manual": {
"message": "Servers",
"description": "This string appears in an application drawer as the header of a section that displays a list of servers."

View file

@ -12,9 +12,82 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// TODO: This is just a stub atm and will need to define and implement the rest
// of the functionality.
import {ManagedServer, RegionId} from './server';
// Keys are region IDs like "us-central1".
// Values are zones like ["us-central1-a", "us-central1-b"].
export type ZoneId = string;
export type ZoneMap = {
[regionId: string]: ZoneId[]
};
export type Project = {
id: string,
name: string,
};
export type BillingAccount = {
id: string,
name: string,
};
/**
* The Google Cloud Platform account model.
*/
export interface Account {
// Returns a user-friendly name associated with the account.
/**
* Returns a globally unique identifier for this Account.
*/
getId(): string;
/**
* Returns a user-friendly name associated with the account.
*/
getName(): Promise<string>;
/**
* Creates an Outline server on a Google Compute Engine VM instance.
*
* This method returns after the VM instance has been created. The Shadowbox
* Outline server may not be fully installed. See {@link ManagedServer#waitOnInstall}
* to be notified when the server installation has completed.
*
* @param projectId - The GCP project ID.
* @param name - The name to be given to the server.
* @param zoneId - The ID of the GCP zone to create the server in.
*/
createServer(projectId: string, name: string, zoneId: string): Promise<ManagedServer>;
/**
* Lists the Outline servers in a given GCP project.
*
* @param projectId - The GCP project ID.
*/
listServers(projectId: string): Promise<ManagedServer[]>;
/**
* Lists the Google Compute Engine locations available to given GCP project.
*
* @param projectId - The GCP project ID.
*/
listLocations(projectId: string): Promise<ZoneMap>;
/**
* Creates a new Google Cloud Platform project.
*
* The project ID must conform to the following:
* - must be 6 to 30 lowercase letters, digits, or hyphens
* - must start with a letter
* - no trailing hyphens
*
* @param id - The project ID.
* @param billingAccount - The billing account ID.
*/
createProject(id: string, billingAccountId: string): Promise<Project>;
/** Lists the Google Cloud Platform projects available with the user. */
listProjects(): Promise<Project[]>;
/** Lists the Google Cloud Platform billing accounts associated with the user. */
listBillingAccounts(): Promise<BillingAccount[]>;
}

View file

@ -23,11 +23,12 @@ import * as digitalocean from '../model/digitalocean';
import * as gcp from '../model/gcp';
import * as server from '../model/server';
import {bytesToDisplayDataAmount, DisplayDataAmount, displayDataAmountToBytes,} from './data_formatting';
import {DisplayDataAmount, displayDataAmountToBytes,} from './data_formatting';
import * as digitalocean_server from './digitalocean_server';
import {DigitalOceanServer} from './digitalocean_server';
import {GcpServer} from './gcp_server';
import {parseManualServerConfig} from './management_urls';
import {AppRoot, ServerListEntry} from './ui_components/app-root';
import {OutlinePerKeyDataLimitDialog} from './ui_components/outline-per-key-data-limit-dialog.js';
import {Location} from './ui_components/outline-region-picker-step';
import {DisplayAccessKey, ServerView} from './ui_components/outline-server-view';
@ -56,9 +57,6 @@ const DIGITALOCEAN_FLAG_MAPPING: {[cityId: string]: string} = {
nyc: `${FLAG_IMAGE_DIR}/us.png`,
};
function dataLimitToDisplayDataAmount(limit: server.DataLimit): DisplayDataAmount|null {
return bytesToDisplayDataAmount(limit?.bytes);
}
function displayDataAmountToDataLimit(dataAmount: DisplayDataAmount): server.DataLimit|null {
if (!dataAmount) {
return null;
@ -149,13 +147,22 @@ export class App {
appRoot.addEventListener(
'ConnectGcpAccountRequested',
async (event: CustomEvent) => this.handleConnectGcpAccountRequest());
appRoot.addEventListener(
'CreateGcpServerRequested',
async (event: CustomEvent) => console.log('Received CreateGcpServerRequested event'));
appRoot.addEventListener('SignOutRequested', (event: CustomEvent) => {
appRoot.addEventListener('CreateGcpServerRequested', async (event: CustomEvent) => {
this.appRoot.getAndShowGcpCreateServerApp().start(this.gcpAccount);
});
appRoot.addEventListener('GcpServerCreated', (event: CustomEvent) => {
const server = event.detail.server;
this.addServer(this.gcpAccount.getId(), server);
this.showServer(server);
});
appRoot.addEventListener('DigitalOceanSignOutRequested', (event: CustomEvent) => {
this.disconnectDigitalOceanAccount();
this.showIntro();
});
appRoot.addEventListener('GcpSignOutRequested', (event: CustomEvent) => {
this.disconnectGcpAccount();
this.showIntro();
});
appRoot.addEventListener('SetUpServerRequested', (event: CustomEvent) => {
this.createDigitalOceanServer(event.detail.regionId);
@ -317,10 +324,10 @@ export class App {
async start(): Promise<void> {
this.showIntro();
// Load server list. Fetch manual and managed servers in parallel.
// Load connected accounts and servers.
await Promise.all([
this.loadDigitalOceanServers(this.cloudAccounts.getDigitalOceanAccount()),
this.loadManualServers()
this.loadDigitalOceanAccount(this.cloudAccounts.getDigitalOceanAccount()),
this.loadGcpAccount(this.cloudAccounts.getGcpAccount()), this.loadManualServers()
]);
// Show last displayed server, if any.
@ -333,7 +340,7 @@ export class App {
}
}
private async loadDigitalOceanServers(digitalOceanAccount: digitalocean.Account):
private async loadDigitalOceanAccount(digitalOceanAccount: digitalocean.Account):
Promise<server.ManagedServer[]> {
if (!digitalOceanAccount) {
return [];
@ -361,6 +368,26 @@ export class App {
return [];
}
private async loadGcpAccount(gcpAccount: gcp.Account): Promise<server.ManagedServer[]> {
if (!gcpAccount) {
return [];
}
this.gcpAccount = gcpAccount;
this.appRoot.gcpAccount = {id: this.gcpAccount.getId(), name: await this.gcpAccount.getName()};
const result = [];
const gcpProjects = await this.gcpAccount.listProjects();
for (const gcpProject of gcpProjects) {
const servers = await this.gcpAccount.listServers(gcpProject.id);
for (const server of servers) {
this.addServer(this.gcpAccount.getId(), server);
result.push(server);
}
}
return result;
}
private async loadManualServers() {
for (const server of await this.manualServerRepository.listServers()) {
this.addServer(null, server);
@ -379,10 +406,14 @@ export class App {
private makeDisplayName(server: server.Server): string {
let name = server.getName() ?? server.getHostnameForAccessKeys();
if (!name) {
if (isManagedServer(server)) {
// Newly created servers will not have a name.
name = this.makeLocalizedServerName(server.getHost().getRegionId());
let location = null;
// Newly created servers will not have a name.
if (server instanceof DigitalOceanServer) {
location = this.getLocalizedCityName(server.getHost().getRegionId());
} else if (server instanceof GcpServer) {
location = server.getHost().getRegionId();
}
name = this.makeLocalizedServerName(location);
}
return name;
}
@ -567,7 +598,7 @@ export class App {
}
private async handleConnectDigitalOceanAccountRequest(): Promise<void> {
let digitalOceanAccount: digitalocean.Account;
let digitalOceanAccount: digitalocean.Account = null;
try {
const accessToken = await this.runDigitalOceanOauthFlow();
digitalOceanAccount = this.cloudAccounts.connectDigitalOceanAccount(accessToken);
@ -581,18 +612,20 @@ export class App {
}
return;
}
const doServers = await this.loadDigitalOceanServers(digitalOceanAccount);
const doServers = await this.loadDigitalOceanAccount(digitalOceanAccount);
if (doServers.length > 0) {
this.showServer(doServers[0]);
} else {
await this.showDigitalOceanCreateServer(digitalOceanAccount);
await this.showDigitalOceanCreateServer(this.digitalOceanAccount);
}
}
private async handleConnectGcpAccountRequest(): Promise<void> {
let gcpAccount: gcp.Account = null;
try {
const refreshToken = await this.runGcpOauthFlow();
this.gcpAccount = this.cloudAccounts.connectGcpAccount(refreshToken);
gcpAccount = this.cloudAccounts.connectGcpAccount(refreshToken);
} catch (error) {
this.disconnectGcpAccount();
this.showIntro();
@ -604,7 +637,7 @@ export class App {
return;
}
this.appRoot.gcpAccountName = await this.gcpAccount.getName();
await this.loadGcpAccount(gcpAccount);
this.showIntro();
}
@ -627,9 +660,19 @@ export class App {
// Clears the GCP credentials and returns to the intro screen.
private disconnectGcpAccount(): void {
if (!this.gcpAccount) {
// Not connected.
return;
}
const accountId = this.gcpAccount.getId();
this.cloudAccounts.disconnectGcpAccount();
this.gcpAccount = null;
this.appRoot.gcpAccountName = '';
for (const serverEntry of this.appRoot.serverList) {
if (serverEntry.accountId === accountId) {
this.removeServer(serverEntry.id);
}
}
this.appRoot.gcpAccount = null;
}
// Opens the screen to create a server.
@ -669,7 +712,8 @@ export class App {
// Shadowbox may not be fully installed once this promise is fulfilled.
public async createDigitalOceanServer(regionId: server.RegionId): Promise<void> {
try {
const serverName = this.makeLocalizedServerName(regionId);
const serverLocation = this.getLocalizedCityName(regionId);
const serverName = this.makeLocalizedServerName(serverLocation);
const server = await this.digitalOceanRetry(() => {
return this.digitalOceanAccount.createServer(regionId, serverName);
});
@ -686,9 +730,8 @@ export class App {
return this.appRoot.localize(`city-${cityId}`);
}
private makeLocalizedServerName(regionId: server.RegionId): string {
const serverLocation = this.getLocalizedCityName(regionId);
return this.appRoot.localize('server-name', 'serverLocation', serverLocation);
private makeLocalizedServerName(location: string): string {
return this.appRoot.localize('server-name', 'serverLocation', location);
}
public showServer(server: server.Server): void {

View file

@ -23,11 +23,14 @@ rm -rf "${OUT_DIR}"
mkdir -p "${OUT_DIR}"
pushd "${ROOT_DIR}/src/server_manager/install_scripts" > /dev/null
tar --create --gzip -f "${OUT_DIR}/scripts.tgz" ./*.sh
tar --create --gzip -f "${OUT_DIR}/do_scripts.tgz" ./install_server.sh ./do_install_server.sh
tar --create --gzip -f "${OUT_DIR}/gcp_scripts.tgz" ./install_server.sh ./gcp_install_server.sh
# Node.js on Cygwin doesn't like absolute Unix-style paths.
# So, we use a relative path as input.
cd "${ROOT_DIR}"
node src/server_manager/install_scripts/build_install_script_ts.node.js \
build/server_manager/web_app/sh/scripts.tgz > "${ROOT_DIR}/src/server_manager/install_scripts/do_install_script.ts"
node src/server_manager/install_scripts/build_do_install_script_ts.node.js \
build/server_manager/web_app/sh/do_scripts.tgz > "${ROOT_DIR}/src/server_manager/install_scripts/do_install_script.ts"
node src/server_manager/install_scripts/build_gcp_install_script_ts.node.js \
build/server_manager/web_app/sh/gcp_scripts.tgz > "${ROOT_DIR}/src/server_manager/install_scripts/gcp_install_script.ts"
popd > /dev/null

View file

@ -123,7 +123,7 @@ export class CloudAccounts implements accounts.CloudAccounts {
}
private createGcpAccount(refreshToken: string): GcpAccount {
return new GcpAccount(refreshToken);
return new GcpAccount('gcp', refreshToken);
}
private save(): void {

View file

@ -15,6 +15,7 @@
import '../ui_components/outline-about-dialog';
import '../ui_components/outline-do-oauth-step';
import '../ui_components/outline-gcp-oauth-step';
import '../ui_components/outline-gcp-create-server-app';
import '../ui_components/outline-feedback-dialog';
import '../ui_components/outline-share-dialog';
import '../ui_components/outline-sort-span';
@ -25,6 +26,9 @@ import '@polymer/paper-checkbox/paper-checkbox';
import {PaperCheckboxElement} from '@polymer/paper-checkbox/paper-checkbox';
import IntlMessageFormat from 'intl-messageformat';
import {css, customElement, html, LitElement, property} from 'lit-element';
import * as gcp from '../../model/gcp';
import {FakeGcpAccount} from '../testing/models';
import {OutlinePerKeyDataLimitDialog} from '../ui_components/outline-per-key-data-limit-dialog';
async function makeLocalize(language: string) {
@ -51,12 +55,22 @@ async function makeLocalize(language: string) {
};
}
const GCP_LOCATIONS: gcp.ZoneMap = {
'us-central1': ['us-central1-a', 'us-central1-b', 'us-central1-c'],
'asia-east1': ['asia-east1-a', 'asia-east1-b'],
'europe-west1': ['europe-west1-a', 'europe-west1-b', 'europe-west1-c'],
};
const GCP_BILLING_ACCOUNTS: gcp.BillingAccount[] =
[{id: '1234-123456', name: 'My Billing Account'}];
@customElement('outline-test-app')
export class TestApp extends LitElement {
@property({type: String}) dir = 'ltr';
@property({type: Function}) localize: (...args: string[]) => string;
@property({type: Boolean}) savePerKeyDataLimitSuccessful = true;
@property({type: Number}) keyDataLimit: number|undefined;
@property({type: String}) gcpRefreshToken = '';
@property({type: Boolean}) gcpAccountHasBillingAccounts = false;
private language = '';
static get styles() {
@ -120,6 +134,22 @@ export class TestApp extends LitElement {
<h1>Outline Manager Components Gallery</h1>
${this.pageControls}
<div class="widget">
<h2>outline-gcp-create-server-app</h2>
<button @tap=${() => {
const billingAccounts = this.gcpAccountHasBillingAccounts ? GCP_BILLING_ACCOUNTS : [];
const account = new FakeGcpAccount('refresh-token', billingAccounts, GCP_LOCATIONS);
this.select('outline-gcp-create-server-app').start(account);
}}>
Create server</button>
<paper-checkbox
?checked=${this.gcpAccountHasBillingAccounts}
@tap=${() => this.gcpAccountHasBillingAccounts = !this.gcpAccountHasBillingAccounts}
>Fake billing accounts</paper-checkbox>
<outline-gcp-create-server-app .localize=${
this.localize}></outline-gcp-create-server-app>
</div>
<div
class="widget"
id="key-settings-widget"

View file

@ -12,16 +12,302 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as gcp_api from '../cloud/gcp_api';
import {sleep} from '../infrastructure/sleep';
import {SCRIPT} from '../install_scripts/gcp_install_script';
import * as gcp from '../model/gcp';
import {BillingAccount, Project} from '../model/gcp';
import * as server from '../model/server';
import {GcpServer} from './gcp_server';
/**
* The Google Cloud Platform account model.
*/
export class GcpAccount implements gcp.Account {
constructor(private refreshToken: string) {}
private static readonly OUTLINE_PROJECT_NAME = 'Outline servers';
private static readonly OUTLINE_FIREWALL_NAME = 'outline';
private static readonly MACHINE_SIZE = 'f1-micro';
private static readonly REQUIRED_GCP_SERVICES = ['compute.googleapis.com'];
async getName(): Promise<string> {
return 'placeholder';
private readonly apiClient: gcp_api.RestApiClient;
constructor(private id: string, private refreshToken: string) {
this.apiClient = new gcp_api.RestApiClient(refreshToken);
}
getId(): string {
return this.id;
}
/** @see {@link Account#getName}. */
async getName(): Promise<string> {
const userInfo = await this.apiClient.getUserInfo();
return userInfo?.email;
}
/** Returns the refresh token. */
getRefreshToken(): string {
return this.refreshToken;
}
/** @see {@link Account#createServer}. */
async createServer(projectId: string, name: string, zoneId: string):
Promise<server.ManagedServer> {
const instance = await this.createInstance(projectId, name, zoneId);
const id = `${this.id}:${instance.id}`;
return new GcpServer(id, projectId, instance, this.apiClient);
}
/** @see {@link Account#listServers}. */
async listServers(projectId: string): Promise<server.ManagedServer[]> {
const result: GcpServer[] = [];
const listZonesResponse = await this.apiClient.listZones(projectId);
const listInstancesPromises = [];
for (const zone of listZonesResponse.items) {
const filter = 'labels.outline=true';
const listInstancesPromise = this.apiClient.listInstances(projectId, zone.name, filter);
listInstancesPromises.push(listInstancesPromise);
}
const listInstancesResponses = await Promise.all(listInstancesPromises);
for (const response of listInstancesResponses) {
const instances = response.items ?? [];
instances.forEach((instance) => {
const id = `${this.id}:${instance.id}`;
const server = new GcpServer(id, projectId, instance, this.apiClient);
result.push(server);
});
}
return result;
}
/** @see {@link Account#listLocations}. */
async listLocations(projectId: string): Promise<gcp.ZoneMap> {
const listZonesResponse = await this.apiClient.listZones(projectId);
const zones = listZonesResponse.items ?? [];
const result: gcp.ZoneMap = {};
zones.map((zone) => {
const region = zone.region.substring(zone.region.lastIndexOf('/') + 1);
if (!(region in result)) {
result[region] = [];
}
if (zone.status === 'UP') {
result[region].push(zone.name);
}
});
return result;
}
/** @see {@link Account#listProjects}. */
async listProjects(): Promise<Project[]> {
const filter = 'labels.outline=true AND lifecycleState=ACTIVE';
const response = await this.apiClient.listProjects(filter);
if (response.projects?.length > 0) {
return response.projects.map(project => {
return {
id: project.projectId,
name: project.name,
};
});
}
return [];
}
/** @see {@link Account#createProject}. */
async createProject(projectId: string, billingAccountId: string): Promise<Project> {
// Create GCP project
const createProjectData = {
projectId,
name: GcpAccount.OUTLINE_PROJECT_NAME,
labels: {
outline: 'true',
},
};
const createProjectResponse = await this.apiClient.createProject(projectId, createProjectData);
let createProjectOperation = null;
while (!createProjectOperation?.done) {
await sleep(2 * 1000);
createProjectOperation =
await this.apiClient.resourceManagerOperationGet(createProjectResponse.name);
}
if (createProjectOperation.error) {
// TODO: Throw error. The project wasn't created so we should have nothing to delete.
}
await this.configureProject(projectId, billingAccountId);
return {
id: projectId,
name: GcpAccount.OUTLINE_PROJECT_NAME,
};
}
async isProjectHealthy(projectId: string): Promise<boolean> {
const projectBillingInfo = await this.apiClient.getProjectBillingInfo(projectId);
if (!projectBillingInfo.billingEnabled) {
return false;
}
const listEnabledServicesResponse = await this.apiClient.listEnabledServices(projectId);
for (const requiredService of GcpAccount.REQUIRED_GCP_SERVICES) {
const found = listEnabledServicesResponse.services.find(
service => service.config.name === requiredService);
if (!found) {
return false;
}
}
return true;
}
async repairProject(projectId: string, billingAccountId: string): Promise<void> {
return await this.configureProject(projectId, billingAccountId);
}
/** @see {@link Account#listBillingAccounts}. */
async listBillingAccounts(): Promise<BillingAccount[]> {
const response = await this.apiClient.listBillingAccounts();
if (response.billingAccounts?.length > 0) {
return response.billingAccounts.map(billingAccount => {
return {
id: billingAccount.name.substring(billingAccount.name.lastIndexOf('/') + 1),
name: billingAccount.displayName,
};
});
}
return [];
}
private async createInstance(projectId: string, name: string, zoneId: string):
Promise<gcp_api.Instance> {
// Configure Outline firewall
const getFirewallResponse =
await this.apiClient.listFirewalls(projectId, GcpAccount.OUTLINE_FIREWALL_NAME);
if (!getFirewallResponse?.items || getFirewallResponse?.items?.length === 0) {
const createFirewallData = this.makeCreateFirewallRequestData(name);
const createFirewallOperation = await this.apiClient.createFirewall(projectId, createFirewallData);
if (createFirewallOperation.error?.errors) {
// TODO: Throw error.
}
}
// Create VM instance
const createInstanceData = this.makeCreateInstanceRequestData(name, zoneId);
const createInstanceOperation =
await this.apiClient.createInstance(projectId, zoneId, createInstanceData);
if (createInstanceOperation.error?.errors) {
// TODO: Throw error.
}
const instance =
await this.apiClient.getInstance(projectId, createInstanceOperation.targetId, zoneId);
// Promote ephemeral IP to static IP
const regionId = zoneId.substring(0, zoneId.lastIndexOf('-'));
const ipAddress = instance.networkInterfaces[0].accessConfigs[0].natIP;
const createStaticIpData = {
name,
address: ipAddress,
};
const createStaticIpOperation =
await this.apiClient.createStaticIp(projectId, regionId, createStaticIpData);
if (createStaticIpOperation.error?.errors) {
// TODO: Delete VM instance. Throw error.
}
return instance;
}
private async configureProject(projectId: string, billingAccountId: string): Promise<void> {
// Link billing account
const updateProjectBillingInfoData =
this.makeUpdateProjectBillingInfoRequestData(projectId, billingAccountId);
await this.apiClient.updateProjectBillingInfo(projectId, updateProjectBillingInfoData);
// Enable APIs
const enableServicesData = {
serviceIds: GcpAccount.REQUIRED_GCP_SERVICES,
};
const enableServicesResponse =
await this.apiClient.enableServices(projectId, enableServicesData);
let enableServicesOperation = null;
while (!enableServicesOperation?.done) {
await sleep(2 * 1000);
enableServicesOperation =
await this.apiClient.serviceUsageOperationGet(enableServicesResponse.name);
}
if (enableServicesResponse.error) {
// TODO: Throw error.
}
}
private makeCreateFirewallRequestData(name: string): {} {
return {
name: GcpAccount.OUTLINE_FIREWALL_NAME,
direction: 'INGRESS',
priority: 1000,
targetTags: [name],
allowed: [
{
IPProtocol: 'all',
},
],
sourceRanges: ['0.0.0.0/0'],
};
}
private makeCreateInstanceRequestData(name: string, zoneId: string): {} {
const installScript = this.getInstallScript();
return {
name,
machineType: `zones/${zoneId}/machineTypes/${GcpAccount.MACHINE_SIZE}`,
disks: [
{
boot: true,
initializeParams: {
sourceImage: 'projects/ubuntu-os-cloud/global/images/family/ubuntu-1804-lts',
},
},
],
networkInterfaces: [
{
network: 'global/networks/default',
// Empty accessConfigs necessary to allocate ephemeral IP
accessConfigs: [{}],
},
],
labels: {
outline: 'true',
},
tags: {
// This must match the firewall name.
items: ['outline'],
},
metadata: {
items: [
{
key: 'enable-guest-attributes',
value: 'TRUE',
},
{
key: 'user-data',
value: installScript,
},
],
},
};
}
private makeUpdateProjectBillingInfoRequestData(projectId: string, billingAccountId: string): {} {
return {
name: `projects/${projectId}/billingInfo`,
projectId,
billingAccountName: `billingAccounts/${billingAccountId}`,
};
}
private getInstallScript(): string {
return '#!/bin/bash -eu\n' + SCRIPT;
}
}

View file

@ -0,0 +1,122 @@
// 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 * as gcp_api from '../cloud/gcp_api';
import * as errors from '../infrastructure/errors';
import {sleep} from '../infrastructure/sleep';
import * as server from '../model/server';
import {DataAmount, ManagedServerHost, MonetaryCost} from '../model/server';
import {ShadowboxServer} from './shadowbox_server';
enum InstallState {
// Unknown state - server may still be installing.
UNKNOWN = 0,
// Server is running and has the API URL and certificate fingerprint set.
SUCCESS,
// Server is in an error state.
ERROR,
// Server has been deleted.
DELETED
}
export class GcpServer extends ShadowboxServer implements server.ManagedServer {
private static readonly GUEST_ATTRIBUTES_POLLING_INTERVAL_MS = 5 * 1000;
private readonly gcpHost: GcpHost;
private installState: InstallState = InstallState.UNKNOWN;
constructor(
id: string, private projectId: string, private instance: gcp_api.Instance,
private apiClient: gcp_api.RestApiClient) {
super(id);
this.gcpHost = new GcpHost(projectId, instance, apiClient, this.onDelete.bind(this));
}
getHost(): ManagedServerHost {
return this.gcpHost;
}
isInstallCompleted(): boolean {
return this.installState !== InstallState.UNKNOWN;
}
async waitOnInstall(): Promise<void> {
while (this.installState === InstallState.UNKNOWN) {
const zoneId = this.instance.zone.substring(this.instance.zone.lastIndexOf('/') + 1);
const outlineGuestAttributes =
await this.getOutlineGuestAttributes(this.projectId, this.instance.id, zoneId);
if (outlineGuestAttributes.has('apiUrl') && outlineGuestAttributes.has('certSha256')) {
const certSha256 = outlineGuestAttributes.get('certSha256');
const apiUrl = outlineGuestAttributes.get('apiUrl');
trustCertificate(certSha256);
this.setManagementApiUrl(apiUrl);
this.installState = InstallState.SUCCESS;
} else if (outlineGuestAttributes.has('install-error')) {
this.installState = InstallState.ERROR;
throw new errors.ServerInstallFailedError();
}
await sleep(GcpServer.GUEST_ATTRIBUTES_POLLING_INTERVAL_MS);
}
}
private async getOutlineGuestAttributes(projectId: string, instanceId: string, zone: string):
Promise<Map<string, string>> {
const result = new Map<string, string>();
const guestAttributes =
await this.apiClient.getGuestAttributes(projectId, instanceId, zone, 'outline/');
const attributes = guestAttributes?.queryValue?.items ?? [];
attributes.forEach((entry) => {
result.set(entry.key, entry.value);
});
return result;
}
private onDelete() {
// TODO: Consider setInstallState.
this.installState = InstallState.DELETED;
}
}
class GcpHost implements server.ManagedServerHost {
constructor(
private projectId: string, private instance: gcp_api.Instance,
private apiClient: gcp_api.RestApiClient, private deleteCallback: Function) {}
// TODO: Throw error and show message on failure
async delete(): Promise<void> {
const zoneId = this.instance.zone.substring(this.instance.zone.lastIndexOf('/') + 1);
const regionId = zoneId.substring(0, zoneId.lastIndexOf('-'));
await this.apiClient.deleteStaticIp(this.projectId, this.instance.name, regionId);
this.apiClient.deleteInstance(this.projectId, this.instance.id, zoneId);
this.deleteCallback();
}
getHostId(): string {
return this.instance.id;
}
getMonthlyCost(): MonetaryCost {
return undefined;
}
getMonthlyOutboundTransferLimit(): DataAmount {
return undefined;
}
getRegionId(): string {
return this.instance.zone.substring(this.instance.zone.lastIndexOf('/') + 1);
}
}

View file

@ -25,7 +25,6 @@ export class FakeDigitalOceanAccount implements digitalocean.Account {
getId(): string {
return 'account-id';
}
async getName(): Promise<string> {
return 'fake-digitalocean-account-name';
}
@ -49,14 +48,43 @@ export class FakeDigitalOceanAccount implements digitalocean.Account {
}
export class FakeGcpAccount implements gcp.Account {
constructor(private refreshToken = 'fake-access-token') {}
constructor(
private refreshToken = 'fake-access-token',
private billingAccounts: gcp.BillingAccount[] = [], private locations: gcp.ZoneMap = {}) {}
getId() {
return 'id';
}
async getName(): Promise<string> {
return 'fake-gcp-account-name';
}
getRefreshToken(): string {
return this.refreshToken;
}
createServer(projectId: string, name: string, zoneId: string): Promise<server.ManagedServer> {
return undefined;
}
async listLocations(projectId: string): Promise<Readonly<gcp.ZoneMap>> {
return this.locations;
}
async listServers(projectId: string): Promise<server.ManagedServer[]> {
return [];
}
async createProject(id: string, billingAccountId: string): Promise<gcp.Project> {
return {
id: 'project-id',
name: 'project-name',
};
}
async isProjectHealthy(projectId: string): Promise<boolean> {
return true;
}
async listBillingAccounts(): Promise<gcp.BillingAccount[]> {
return this.billingAccounts;
}
async listProjects(): Promise<gcp.Project[]> {
return [];
}
}
export class FakeServer implements server.Server {

View file

@ -30,6 +30,7 @@ import './cloud-install-styles.js';
import './outline-about-dialog.js';
import './outline-do-oauth-step.js';
import './outline-gcp-oauth-step';
import './outline-gcp-create-server-app';
import './outline-feedback-dialog.js';
import './outline-survey-dialog.js';
import './outline-intro-step.js';
@ -46,8 +47,6 @@ import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {displayDataAmountToBytes} from '../data_formatting';
import {ServerView} from './outline-server-view.js';
const TOS_ACK_LOCAL_STORAGE_KEY = 'tos-ack';
@ -389,9 +388,10 @@ export class AppRoot extends mixinBehaviors
<app-header-layout>
<div class="app-container">
<iron-pages attr-for-selected="id" selected="{{ currentPage }}">
<outline-intro-step id="intro" digital-ocean-account-name="{{digitalOceanAccount.name}}" gcp-account-name="{{gcpAccountName}}" localize="[[localize]]"></outline-intro-step>
<outline-intro-step id="intro" digital-ocean-account-name="{{digitalOceanAccount.name}}" gcp-account-name="{{gcpAccount.name}}" localize="[[localize]]"></outline-intro-step>
<outline-do-oauth-step id="digitalOceanOauth" localize="[[localize]]"></outline-do-oauth-step>
<outline-gcp-oauth-step id="gcpOauth" localize="[[localize]]"></outline-gcp-oauth-step>
<outline-gcp-create-server-app id="gcpCreateServer" localize="[[localize]]"></outline-gcp-create-server-app>
<outline-manual-server-entry id="manualEntry" localize="[[localize]]"></outline-manual-server-entry>
<outline-region-picker-step id="regionPicker" localize="[[localize]]"></outline-region-picker-step>
<outline-server-list id="serverView" server-list="[[serverList]]" selected-server-id="[[selectedServerId]]" language="[[language]]" localize="[[localize]]"></outline-server-list>
@ -461,7 +461,7 @@ export class AppRoot extends mixinBehaviors
<div class="do-overflow-menu" slot="dropdown-content">
<h4>[[localize('digitalocean-disconnect-account')]]</h4>
<div class="account-info"><img src="images/digital_ocean_logo.svg">[[digitalOceanAccount.name]]</div>
<div class="sign-out-button" on-tap="signOutTapped">[[localize('digitalocean-disconnect')]]</div>
<div class="sign-out-button" on-tap="_digitalOceanSignOutTapped">[[localize('digitalocean-disconnect')]]</div>
</div>
</paper-menu-button>
</div>
@ -474,7 +474,28 @@ export class AppRoot extends mixinBehaviors
</template>
</div>
</div>
<!-- TODO(fortuna): Insert GCP servers here -->
<!-- GCP servers -->
<div class="servers-section" hidden\$="[[!gcpAccount]]">
<div class="servers-header">
<span>[[localize('servers-gcp')]]</span>
<paper-menu-button horizontal-align="left" class="" close-on-activate="" no-animations="" dynamic-align="" no-overlap="">
<paper-icon-button icon="more-vert" slot="dropdown-trigger"></paper-icon-button>
<div class="do-overflow-menu" slot="dropdown-content">
<h4>[[localize('gcp-disconnect-account')]]</h4>
<div class="account-info"><img src="images/gcp-logo.svg">[[gcpAccount.name]]</div>
<div class="sign-out-button" on-tap="_gcpSignOutTapped">[[localize('gcp-disconnect')]]</div>
</div>
</paper-menu-button>
</div>
<div class="servers-container">
<template is="dom-repeat" items="[[serverList]]" as="server" filter="[[_accountServerFilter(gcpAccount)]]" sort="_sortServersByName">
<div class\$="server [[_computeServerClasses(selectedServerId, server)]]" data-server\$="[[server]]" on-tap="_showServer">
<img class="server-icon" src\$="images/[[_computeServerImage(selectedServerId, server)]]">
<span>[[server.name]]</span>
</div>
</template>
</div>
</div>
<!-- Manual servers -->
<div class="servers-section" hidden\$="[[!_hasManualServers(serverList)]]">
<div class="servers-header">
@ -503,7 +524,15 @@ export class AppRoot extends mixinBehaviors
</div>
</template>
</div>
<!-- TODO(fortuna): Insert GCP servers here -->
<!-- GCP servers -->
<div class="side-bar-section servers-section" hidden\$="[[!gcpAccount]]">
<img class="provider-icon" src="images/gcp-logo.svg">
<template is="dom-repeat" items="[[serverList]]" as="server" filter="[[_accountServerFilter(gcpAccount)]]" sort="_sortServersByName">
<div class\$="server [[_computeServerClasses(selectedServerId, server)]]" data-server\$="[[server]]" on-tap="_showServer">
<img class="server-icon" src\$="images/[[_computeServerImage(selectedServerId, server)]]">
</div>
</template>
</div>
<!-- Manual servers -->
<div class="side-bar-section servers-section" hidden\$="[[!_hasManualServers(serverList)]]">
<img class="provider-icon" src="images/cloud.svg">
@ -650,6 +679,12 @@ export class AppRoot extends mixinBehaviors
return oauthFlow;
}
/** @return {GcpCreateServerApp} */
getAndShowGcpCreateServerApp() {
this.currentPage = 'gcpCreateServer';
return this.$.gcpCreateServer;
}
getAndShowRegionPicker() {
this.currentPage = 'regionPicker';
this.$.regionPicker.reset();
@ -817,8 +852,12 @@ export class AppRoot extends mixinBehaviors
this.maybeCloseDrawer();
}
signOutTapped() {
this.fire('SignOutRequested');
_digitalOceanSignOutTapped() {
this.fire('DigitalOceanSignOutRequested');
}
_gcpSignOutTapped() {
this.fire('GcpSignOutRequested');
}
openManualInstallFeedback(/** @type {string} */ prepopulatedMessage) {

View file

@ -0,0 +1,408 @@
// 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 '@polymer/paper-dropdown-menu/paper-dropdown-menu.js';
import '@polymer/paper-listbox/paper-listbox.js';
import '@polymer/paper-input/paper-input.js';
import '@polymer/paper-item/paper-item.js';
import './outline-region-picker-step';
import {css, customElement, html, internalProperty, LitElement, property} from 'lit-element';
import {BillingAccount, Project} from '../../model/gcp';
import {GcpAccount} from '../gcp_account';
import {COMMON_STYLES} from './cloud-install-styles';
import {Location, OutlineRegionPicker} from './outline-region-picker-step';
// TODO: Map region ids to country codes.
/** @see https://cloud.google.com/compute/docs/regions-zones */
const LOCATION_MAP = new Map<string, string>([
['asia-east1', 'Changhua County, Taiwan'],
['asia-east2', 'Hong Kong'],
['asia-northeast1', 'Tokyo, Japan'],
['asia-northeast2', 'Osaka, Japan'],
['asia-northeast3', 'Seoul, South Korea'],
['asia-south1', 'Mumbai, India'],
['asia-southeast1', 'Jurong West, Singapore'],
['asia-southeast2', 'Jakarta, Indonesia'],
['australia-southeast1', 'Sydney, Australia'],
['europe-north1', 'Hamina, Finland'],
['europe-west1', 'St. Ghislain, Belgium'],
['europe-west2', 'London, England, UK'],
['europe-west3', 'Frankfurt, Germany'],
['europe-west4', 'Eemshaven, Netherlands'],
['europe-west6', 'Zürich, Switzerland'],
['europe-central2', 'Warsaw, Poland, Europe'],
['northamerica-northeast1', 'Montréal, Québec, Canada'],
['southamerica-east1', 'Osasco (São Paulo), Brazil'],
['us-central1', 'Council Bluffs, Iowa, USA'],
['us-east1', 'Moncks Corner, South Carolina, USA'],
['us-east4', 'Ashburn, Northern Virginia, USA'],
['us-west1', 'The Dalles, Oregon, USA'],
['us-west2', 'Los Angeles, California, USA'],
['us-west3', 'Salt Lake City, Utah, USA'],
['us-west4', 'Las Vegas, Nevada, USA'],
]);
// GCP mapping of regions to flags
const FLAG_IMAGE_DIR = 'images/flags';
const GCP_FLAG_MAPPING: {[regionId: string]: string} = {
// 'asia-east1': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'asia-east2': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'asia-northeast1': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'asia-northeast2': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'asia-northeast3': `${FLAG_IMAGE_DIR}/unknown.png`,
'asia-south1': `${FLAG_IMAGE_DIR}/india.png`,
'asia-southeast1': `${FLAG_IMAGE_DIR}/singapore.png`,
// 'asia-southeast2': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'australia-southeast1': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'europe-north1': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'europe-west1': `${FLAG_IMAGE_DIR}/unknown.png`,
'europe-west2': `${FLAG_IMAGE_DIR}/uk.png`,
'europe-west3': `${FLAG_IMAGE_DIR}/germany.png`,
'europe-west4': `${FLAG_IMAGE_DIR}/netherlands.png`,
// 'europe-west6': `${FLAG_IMAGE_DIR}/unknown.png`,
// 'europe-central2': `${FLAG_IMAGE_DIR}/unknown.png`,
'northamerica-northeast1': `${FLAG_IMAGE_DIR}/canada.png`,
// 'southamerica-east1': `${FLAG_IMAGE_DIR}/unknown.png`,
'us-central1': `${FLAG_IMAGE_DIR}/us.png`,
'us-east1': `${FLAG_IMAGE_DIR}/us.png`,
'us-east4': `${FLAG_IMAGE_DIR}/us.png`,
'us-west1': `${FLAG_IMAGE_DIR}/us.png`,
'us-west2': `${FLAG_IMAGE_DIR}/us.png`,
'us-west3': `${FLAG_IMAGE_DIR}/us.png`,
'us-west4': `${FLAG_IMAGE_DIR}/us.png`,
};
// TODO: Handle network and authentication errors
@customElement('outline-gcp-create-server-app')
export class GcpCreateServerApp extends LitElement {
@property({type: Function}) localize: Function;
@internalProperty() private currentPage = '';
@internalProperty() private selectedProjectId = '';
@internalProperty() private selectedBillingAccountId = '';
@internalProperty() private isProjectBeingCreated = false;
private account: GcpAccount;
private project: Project;
private billingAccounts: BillingAccount[] = [];
private regionPicker: OutlineRegionPicker;
static get styles() {
return [
COMMON_STYLES, css`
:host {
--paper-input-container-input-color: var(--medium-gray);
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
align-items: center;
padding: 132px 0;
font-size: 14px;
}
.card {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
margin: 24px 0;
padding: 24px;
background: var(--background-contrast-color);
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.section {
padding: 24px 12px;
color: var(--light-gray);
background: var(--background-contrast-color);
border-radius: 2px;
}
.section:not(:first-child) {
margin-top: 8px;
}
.section-header {
padding: 0 6px 0;
display: flex;
}
.section-content {
padding: 0 48px;
}
.instructions {
font-size: 16px;
line-height: 26px;
margin-left: 16px;
flex: 2;
}
.stepcircle {
height: 26px;
width: 26px;
font-size: 14px;
border-radius: 50%;
float: left;
vertical-align: middle;
color: #000;
background-color: #fff;
margin: auto;
text-align: center;
line-height: 26px;
}
@media (min-width: 1025px) {
paper-card {
/* Set min with for the paper-card to grow responsively. */
min-width: 600px;
}
}
.card p {
color: var(--light-gray);
width: 100%;
text-align: center;
}
#projectName {
width: 250px;
}
#billingAccount {
width: 250px;
}
paper-button {
background: var(--primary-green);
color: var(--light-gray);
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 2px;
}
paper-button[disabled] {
color: var(--medium-gray);
background: transparent;
}`
];
}
render() {
switch (this.currentPage) {
case 'billingAccountSetup':
return this.renderBillingAccountSetup();
case 'projectSetup':
return this.renderProjectSetup();
case 'regionPicker':
return this.renderRegionPicker();
default: {
}
}
}
private renderBillingAccountSetup() {
return html`
<outline-step-view id="billingAccountSetup" display-action="">
<span slot="step-title">Activate your Google Cloud Platform account.</span>
<span slot="step-description">Enter your billing information on Google Cloud Platform.</span>
<span slot="step-action">
<paper-button id="createServerButton" @tap="${this.handleBillingVerificationNextTap}">
NEXT
</paper-button>
</span>
<paper-card class="card">
<div class="container">
<img src="images/do_oauth_billing.svg">
<p>Enter you billing information on Google Cloud Platform</p>
<!-- TODO: Add call to action to open GCP billing accounts page -->
<!-- https://console.cloud.google.com/billing -->
</div>
</paper-card>
</outline-step-view>`;
}
private renderProjectSetup() {
return html`
<outline-step-view id="projectSetup" display-action="">
<span slot="step-title">Create your Google Cloud Platform project.</span>
<span slot="step-description">This will create a new project on your GCP account to hold your Outline servers.</span>
<span slot="step-action">
<paper-button
id="createServerButton"
@tap="${this.handleProjectSetupNextTap}"
?disabled="${
!this.isProjectSetupNextEnabled(this.selectedProjectId, this.selectedBillingAccountId)}">
CREATE PROJECT
</paper-button>
</span>
<div class="section">
<div class="section-header">
<span class="stepcircle">1</span>
<div class="instructions">
Name your new Google Cloud Project
</div>
</div>
<div class="section-content">
<!-- TODO: Make readonly if project already exists -->
<paper-input id="projectName" value="${this.selectedProjectId}"
label="Project ID" always-float-label="" maxlength="100" @value-changed="${
this.onProjectIdChanged}"></paper-input>
</div>
</div>
<div class="section">
<div class="section-header">
<span class="stepcircle">2</span>
<div class="instructions">
Choose your preferred billing method for this project
</div>
</div>
<div class="section-content">
<paper-dropdown-menu id="billingAccount" no-label-float="">
<paper-listbox slot="dropdown-content" selected="${
this.selectedBillingAccountId}" attr-for-selected="name" @selected-changed="${
this.onBillingAccountSelected}">
${this.billingAccounts.map(billingAccount => {
return html`<paper-item name="${billingAccount.id}">${billingAccount.name}</paper-item>`;
})}
</paper-listbox>
</paper-dropdown-menu>
</div>
</div>
${
this.isProjectBeingCreated ?
html`<paper-progress indeterminate="" class="slow"></paper-progress>` :
''}
</outline-step-view>`;
}
private renderRegionPicker() {
return html`
<outline-region-picker-step id="regionPicker" .localize=${this.localize} @RegionSelected="${
this.onRegionSelected}">
</outline-region-picker-step>`;
}
async start(account: GcpAccount): Promise<void> {
this.init();
this.account = account;
this.billingAccounts = await this.account.listBillingAccounts();
const projects = await this.account.listProjects();
// TODO: We don't support multiple projects atm, but we will want to allow
// the user to choose the appropriate one.
this.project = projects?.[0];
const isProjectHealthy =
this.project ? await this.account.isProjectHealthy(this.project.id) : false;
if (this.project && isProjectHealthy) {
this.showRegionPicker();
} else {
if (!this.billingAccounts || this.billingAccounts.length === 0) {
this.showBillingAccountSetup();
} else {
this.showProjectSetup(this.project);
}
}
}
private init() {
this.currentPage = '';
this.selectedProjectId = '';
this.selectedBillingAccountId = '';
}
private showBillingAccountSetup(): void {
this.currentPage = 'billingAccountSetup';
}
private async handleBillingVerificationNextTap(): Promise<void> {
this.showProjectSetup();
}
private async showProjectSetup(existingProject?: Project): Promise<void> {
this.billingAccounts = await this.account.listBillingAccounts();
if (!this.billingAccounts || this.billingAccounts.length === 0) {
return this.showBillingAccountSetup();
}
this.project = existingProject ?? null;
this.selectedProjectId = this.project?.id ?? this.makeProjectName();
this.selectedBillingAccountId = this.billingAccounts[0].id;
this.currentPage = 'projectSetup';
}
private isProjectSetupNextEnabled(projectId: string, billingAccountId: string): boolean {
// TODO: Proper validation
return projectId !== '' && billingAccountId !== '';
}
private async handleProjectSetupNextTap(): Promise<void> {
this.isProjectBeingCreated = true;
if (!this.project) {
this.project =
await this.account.createProject(this.selectedProjectId, this.selectedBillingAccountId);
} else {
await this.account.repairProject(this.project.id, this.selectedBillingAccountId);
}
this.isProjectBeingCreated = false;
this.showRegionPicker();
}
private async showRegionPicker(): Promise<void> {
const isProjectHealthy = await this.account.isProjectHealthy(this.project.id);
if (!isProjectHealthy) {
return this.showProjectSetup();
}
this.currentPage = 'regionPicker';
const regionMap = await this.account.listLocations(this.project.id);
const locations = Object.entries(regionMap).map(([regionId, zoneIds]) => {
return this.createLocationModel(regionId, zoneIds);
});
this.regionPicker = this.shadowRoot.querySelector('#regionPicker') as OutlineRegionPicker;
this.regionPicker.locations = locations;
}
private onProjectIdChanged(event: CustomEvent) {
this.selectedProjectId = event.detail.value;
}
private onBillingAccountSelected(event: CustomEvent) {
this.selectedBillingAccountId = event.detail.value;
}
private async onRegionSelected(event: CustomEvent) {
event.stopPropagation();
this.regionPicker.isServerBeingCreated = true;
const name = this.makeServerName();
const server =
await this.account.createServer(this.project.id, name, event.detail.selectedRegionId);
const params = {bubbles: true, composed: true, detail: {server}};
const serverCreatedEvent = new CustomEvent('GcpServerCreated', params);
this.dispatchEvent(serverCreatedEvent);
}
private createLocationModel(regionId: string, zoneIds: string[]): Location {
return {
id: zoneIds.length > 0 ? zoneIds[0] : null,
name: LOCATION_MAP.get(regionId) ?? regionId,
flag: GCP_FLAG_MAPPING[regionId] || `${FLAG_IMAGE_DIR}/unknown.png`,
available: zoneIds.length > 0,
};
}
private makeProjectName(): string {
return `outline-${Math.random().toString(20).substring(3)}`;
}
private makeServerName(): string {
const now = new Date();
return `outline-${now.getFullYear()}${now.getMonth()}${now.getDate()}-${now.getUTCHours()}${
now.getUTCMinutes()}${now.getUTCSeconds()}`;
}
}

View file

@ -181,38 +181,39 @@ Polymer({
<div class="container">
<div id="digital-ocean" class="card" on-tap="connectToDigitalOceanTapped">
<div class="card-header">
<div class="tag" hidden\$="{{isDigitalOceanAccountConnected}}">[[localize('setup-recommended')]]</div>
<div class="email" hidden\$="{{!isDigitalOceanAccountConnected}}">{{digitalOceanAccountName}}</div>
<div class="tag" hidden\$="[[_computeIsAccountConnected(digitalOceanAccountName)]]">[[localize('setup-recommended')]]</div>
<div class="email" hidden\$="[[!_computeIsAccountConnected(digitalOceanAccountName)]]">[[digitalOceanAccountName]]</div>
<img src="images/do_white_logo.svg">
</div>
<div class="card-title">DigitalOcean</div>
<div class="card-body">
<div class="description">
<ul hidden\$="{{isDigitalOceanAccountConnected}}">
<ul hidden\$="[[_computeIsAccountConnected(digitalOceanAccountName)]]">
<li>[[localize('setup-do-easiest')]]</li>
<li>[[localize('setup-do-cost')]]</li>
<li>[[localize('setup-do-data')]]</li>
<li>[[localize('setup-do-cancel')]]</li>
</ul>
<p hidden\$="{{!isDigitalOceanAccountConnected}}">
<p hidden\$="[[!_computeIsAccountConnected(digitalOceanAccountName)]]">
[[localize('setup-do-create')]]
</p>
</div>
</div>
<div class="card-footer">
<paper-button class="primary" hidden\$="{{isDigitalOceanAccountConnected}}">[[localize('setup-action')]]</paper-button>
<paper-button class="primary" hidden\$="{{!isDigitalOceanAccountConnected}}">[[localize('setup-create')]]</paper-button>
<paper-button class="primary" hidden\$="[[_computeIsAccountConnected(digitalOceanAccountName)]]">[[localize('setup-action')]]</paper-button>
<paper-button class="primary" hidden\$="[[!_computeIsAccountConnected(digitalOceanAccountName)]]">[[localize('setup-create')]]</paper-button>
</div>
</div>
<div id="gcp" class="card" on-tap="setUpGcpTapped">
<div class="card-header">
<div class="tag">[[localize('setup-advanced')]]</div>
<div class="tag" hidden\$="[[_computeIsAccountConnected(gcpAccountName)]]">[[localize('setup-advanced')]]</div>
<div class="email" hidden\$="[[!_computeIsAccountConnected(gcpAccountName)]]">[[gcpAccountName]]</div>
<img src="images/gcp-logo.svg">
</div>
<div class="card-title">Google Cloud Platform</div>
<div class="card-body">
<div class="description">
<div class="description" hidden\$="[[_computeIsAccountConnected(gcpAccountName)]]">
<ul>
<li>[[localize('setup-step-by-step')]]</li>
<li>[[localize('setup-firewall-instructions')]]</li>
@ -221,7 +222,8 @@ Polymer({
</div>
</div>
<div class="card-footer">
<paper-button on-tap="setUpGcpTapped" class="primary">[[localize('setup-action')]]</paper-button>
<paper-button class="primary" hidden\$="[[_computeIsAccountConnected(gcpAccountName)]]">[[localize('setup-action')]]</paper-button>
<paper-button class="primary" hidden\$="[[!_computeIsAccountConnected(gcpAccountName)]]">[[localize('setup-create')]]</paper-button>
</div>
</div>
@ -274,10 +276,6 @@ Polymer({
type: String,
value: null,
},
isDigitalOceanAccountConnected: {
type: Boolean,
computed: '_computeIsDigitalOceanAccountConnected(digitalOceanAccountName)',
},
gcpAccountName: {
type: String,
value: null,
@ -288,12 +286,12 @@ Polymer({
},
},
_computeIsDigitalOceanAccountConnected(digitalOceanAccountName) {
return Boolean(digitalOceanAccountName);
_computeIsAccountConnected(accountName) {
return Boolean(accountName);
},
connectToDigitalOceanTapped: function() {
if (this.isDigitalOceanAccountConnected) {
if (this.digitalOceanAccountName) {
this.fire('CreateDigitalOceanServerRequested');
} else {
this.fire('ConnectDigitalOceanAccountRequested');