diff --git a/.gitignore b/.gitignore index bf794f62..e4e43da0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json index d3854ed6..0e5f19d0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/server_manager/cloud/gcp_api.ts b/src/server_manager/cloud/gcp_api.ts new file mode 100644 index 00000000..59615ac1 --- /dev/null +++ b/src/server_manager/cloud/gcp_api.ts @@ -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([ + ['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 { + const operation = await this.fetchAuthenticated( + '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 { + const operation = await this.fetchAuthenticated( + '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 { + 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 { + let parameters = null; + if (filter) { + parameters = new Map([ + ['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 { + const operation = await this.fetchAuthenticated( + '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 { + const operation = await this.fetchAuthenticated( + '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 { + try { + const parameters = new Map([['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 { + const operation = await this.fetchAuthenticated( + '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 { + const filter = `name=${name}`; + const parameters = new Map([['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 { + 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 { + const parameters = new Map([['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 { + 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 { + 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 { + let parameters = null; + if (filter) { + parameters = new Map([ + ['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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.fetchAuthenticated( + 'GET', new URL(`https://cloudresourcemanager.googleapis.com/v1/${operationId}`), + this.GCP_HEADERS); + } + + serviceUsageOperationGet(operationId: string): Promise { + 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 { + const parameters = new Map([['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 { + const headers = new Map( + [['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 { + const headers = new Map( + [['Host', 'oauth2.googleapis.com'], ['Content-Type', 'application/x-www-form-urlencoded']]); + const parameters = new Map([['token', token]]); + return this.fetchUnauthenticated( + 'GET', new URL('https://oauth2.googleapis.com/revoke'), headers, parameters); + } + + // tslint:disable-next-line:no-any + private async fetchAuthenticated(method: string, url: URL, headers: Map, parameters?: Map, data?: any): Promise { + 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(method: string, url: URL, headers: Map, parameters?: Map, data?: any): Promise { + 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('&'); + } +} diff --git a/src/server_manager/electron_app/gcp_oauth.ts b/src/server_manager/electron_app/gcp_oauth.ts index 672e1b12..07cf120d 100644 --- a/src/server_manager/electron_app/gcp_oauth.ts +++ b/src/server_manager/electron_app/gcp_oauth.ts @@ -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'; diff --git a/src/server_manager/images/flags/unknown.png b/src/server_manager/images/flags/unknown.png new file mode 100644 index 00000000..7fc04bfa Binary files /dev/null and b/src/server_manager/images/flags/unknown.png differ diff --git a/src/server_manager/install_scripts/build_install_script_ts.node.js b/src/server_manager/install_scripts/build_do_install_script_ts.node.js similarity index 100% rename from src/server_manager/install_scripts/build_install_script_ts.node.js rename to src/server_manager/install_scripts/build_do_install_script_ts.node.js diff --git a/src/server_manager/install_scripts/build_gcp_install_script_ts.node.js b/src/server_manager/install_scripts/build_gcp_install_script_ts.node.js new file mode 100644 index 00000000..5bcc105b --- /dev/null +++ b/src/server_manager/install_scripts/build_gcp_install_script_ts.node.js @@ -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 ) < "${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}" diff --git a/src/server_manager/messages/en.json b/src/server_manager/messages/en.json index 8c309628..45bbb441 100644 --- a/src/server_manager/messages/en.json +++ b/src/server_manager/messages/en.json @@ -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", diff --git a/src/server_manager/messages/master_messages.json b/src/server_manager/messages/master_messages.json index 467fb1b5..c0a56b31 100644 --- a/src/server_manager/messages/master_messages.json +++ b/src/server_manager/messages/master_messages.json @@ -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." diff --git a/src/server_manager/model/gcp.ts b/src/server_manager/model/gcp.ts index 68f70ce1..0a51c761 100644 --- a/src/server_manager/model/gcp.ts +++ b/src/server_manager/model/gcp.ts @@ -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; + + /** + * 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; + + /** + * Lists the Outline servers in a given GCP project. + * + * @param projectId - The GCP project ID. + */ + listServers(projectId: string): Promise; + + /** + * Lists the Google Compute Engine locations available to given GCP project. + * + * @param projectId - The GCP project ID. + */ + listLocations(projectId: string): Promise; + + /** + * 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; + + /** Lists the Google Cloud Platform projects available with the user. */ + listProjects(): Promise; + + /** Lists the Google Cloud Platform billing accounts associated with the user. */ + listBillingAccounts(): Promise; } diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index eff74170..1cfcfebb 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -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 { 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 { if (!digitalOceanAccount) { return []; @@ -361,6 +368,26 @@ export class App { return []; } + private async loadGcpAccount(gcpAccount: gcp.Account): Promise { + 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 { - 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 { + 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 { 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 { diff --git a/src/server_manager/web_app/build_install_script_action.sh b/src/server_manager/web_app/build_install_script_action.sh index 6d73c09e..ce5966c6 100755 --- a/src/server_manager/web_app/build_install_script_action.sh +++ b/src/server_manager/web_app/build_install_script_action.sh @@ -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 diff --git a/src/server_manager/web_app/cloud_accounts.ts b/src/server_manager/web_app/cloud_accounts.ts index 1f1f85f4..2772d9d9 100644 --- a/src/server_manager/web_app/cloud_accounts.ts +++ b/src/server_manager/web_app/cloud_accounts.ts @@ -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 { diff --git a/src/server_manager/web_app/gallery_app/main.ts b/src/server_manager/web_app/gallery_app/main.ts index 3c9f6221..6f173300 100644 --- a/src/server_manager/web_app/gallery_app/main.ts +++ b/src/server_manager/web_app/gallery_app/main.ts @@ -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 {

Outline Manager Components Gallery

${this.pageControls} +
+

outline-gcp-create-server-app

+ + this.gcpAccountHasBillingAccounts = !this.gcpAccountHasBillingAccounts} + >Fake billing accounts + +
+
{ - 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + return await this.configureProject(projectId, billingAccountId); + } + + /** @see {@link Account#listBillingAccounts}. */ + async listBillingAccounts(): Promise { + 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 { + // 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 { + // 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; + } } diff --git a/src/server_manager/web_app/gcp_server.ts b/src/server_manager/web_app/gcp_server.ts new file mode 100644 index 00000000..4728adff --- /dev/null +++ b/src/server_manager/web_app/gcp_server.ts @@ -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 { + 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> { + const result = new Map(); + 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 { + 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); + } +} diff --git a/src/server_manager/web_app/testing/models.ts b/src/server_manager/web_app/testing/models.ts index 27678818..1b110723 100644 --- a/src/server_manager/web_app/testing/models.ts +++ b/src/server_manager/web_app/testing/models.ts @@ -25,7 +25,6 @@ export class FakeDigitalOceanAccount implements digitalocean.Account { getId(): string { return 'account-id'; } - async getName(): Promise { 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 { return 'fake-gcp-account-name'; } getRefreshToken(): string { return this.refreshToken; } + createServer(projectId: string, name: string, zoneId: string): Promise { + return undefined; + } + async listLocations(projectId: string): Promise> { + return this.locations; + } + async listServers(projectId: string): Promise { + return []; + } + async createProject(id: string, billingAccountId: string): Promise { + return { + id: 'project-id', + name: 'project-name', + }; + } + async isProjectHealthy(projectId: string): Promise { + return true; + } + async listBillingAccounts(): Promise { + return this.billingAccounts; + } + async listProjects(): Promise { + return []; + } } export class FakeServer implements server.Server { diff --git a/src/server_manager/web_app/ui_components/app-root.js b/src/server_manager/web_app/ui_components/app-root.js index 0b0a3b6d..16bf2831 100644 --- a/src/server_manager/web_app/ui_components/app-root.js +++ b/src/server_manager/web_app/ui_components/app-root.js @@ -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
- + + @@ -461,7 +461,7 @@ export class AppRoot extends mixinBehaviors

[[localize('digitalocean-disconnect-account')]]

-
[[localize('digitalocean-disconnect')]]
+
[[localize('digitalocean-disconnect')]]
@@ -474,7 +474,28 @@ export class AppRoot extends mixinBehaviors
- + +
+
+ [[localize('servers-gcp')]] + + +
+

[[localize('gcp-disconnect-account')]]

+ +
[[localize('gcp-disconnect')]]
+
+
+
+
+ +
+
@@ -503,7 +524,15 @@ export class AppRoot extends mixinBehaviors
- + +