cmd/browser: add helper for deploying upspinserver to GCP

This is a first cut at support for automatically deploying upspinserver
to GCP instances, providing a relatively seamless setup process for
first-time (and even existing) users.

I tried to keep most of the GCP-specific stuff in gcp.go, but there are
some GCP-related details in startup.go still. I won't make an effort to
generalize this further until we've decided to support another cloud
service provider here.

There are a few rough edges still. Namely, the region/zone for your
storage bucket and VM instance are hard-coded, but I'll address that in
a future CL. (This change is already huge enough at >1k LoC.)

Change-Id: I08529a3804443b44375fc3137629266316499e13
Reviewed-on: https://upspin-review.googlesource.com/12141
Reviewed-by: David Symonds <dsymonds@golang.org>
Reviewed-by: Rob Pike <r@golang.org>
diff --git a/cmd/browser/gcp.go b/cmd/browser/gcp.go
new file mode 100644
index 0000000..d4c3fd7
--- /dev/null
+++ b/cmd/browser/gcp.go
@@ -0,0 +1,528 @@
+// Copyright 2017 The Upspin Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// TODO: tell the user to remove/deactivate the Owners service account once
+// we're done with it. (Or maybe we can do this mechanically?)
+
+package main
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"upspin.io/flags"
+	"upspin.io/subcmd"
+	"upspin.io/upspin"
+
+	"golang.org/x/oauth2/google"
+	"golang.org/x/oauth2/jwt"
+	compute "google.golang.org/api/compute/v1"
+	"google.golang.org/api/googleapi"
+	iam "google.golang.org/api/iam/v1"
+	servicemanagement "google.golang.org/api/servicemanagement/v1"
+	storage "google.golang.org/api/storage/v1"
+)
+
+// gcpState represents the state of a GCP deployment. As the process proceeds,
+// the fields are populated with nonzero values from top to bottom.
+type gcpState struct {
+	JWTConfig *jwt.Config
+	ProjectID string
+
+	APIsEnabled bool
+
+	Region      string
+	Zone        string
+	MachineType string
+
+	Storage struct {
+		ServiceAccount string
+		PrivateKeyData string
+		Bucket         string
+	}
+
+	Server struct {
+		IPAddr string
+
+		Created bool
+
+		KeyDir   string
+		UserName upspin.UserName
+
+		HostName string
+
+		Configured bool
+	}
+}
+
+// serverConfig returns a *subcmd.ServerConfig that can be used to configure
+// the running upspinserver.
+func (s *gcpState) serverConfig() *subcmd.ServerConfig {
+	return &subcmd.ServerConfig{
+		Addr:        upspin.NetAddr(s.Server.HostName),
+		User:        s.Server.UserName,
+		StoreConfig: s.storeConfig(),
+	}
+}
+
+// storeConfig returns the StoreServer configuration for the upspinserver.
+func (s *gcpState) storeConfig() []string {
+	return []string{
+		"backend=GCS",
+		"defaultACL=publicRead",
+		"gcpBucketName=" + s.Storage.Bucket,
+		"privateKeyData=" + s.Storage.PrivateKeyData,
+	}
+}
+
+// gcpStateFromFile loads the JSON-encoded GCP deployment state file from
+// flags.Config+".gcpstate".
+func gcpStateFromFile() (*gcpState, error) {
+	name := flags.Config + ".gcpState"
+	b, err := ioutil.ReadFile(name)
+	if err != nil {
+		return nil, err
+	}
+	var s gcpState
+	if err := json.Unmarshal(b, &s); err != nil {
+		return nil, err
+	}
+	return &s, nil
+}
+
+// save writes the JSON-encoded GCP deployment state to
+// flags.Config+".gcpstate".
+func (s *gcpState) save() error {
+	name := flags.Config + ".gcpState"
+	b, err := json.Marshal(s)
+	if err != nil {
+		return err
+	}
+	return ioutil.WriteFile(name, b, 0644)
+}
+
+// gcpStateFromPrivateKeyJSON instantiates a new gcpState from the given
+// Google Cloud Platform Service Account JSON Private Key file.
+func gcpStateFromPrivateKeyJSON(b []byte) (*gcpState, error) {
+	cfg, err := google.JWTConfigFromJSON(b, compute.CloudPlatformScope)
+	if err != nil {
+		return nil, err
+	}
+	projectID, err := serviceAccountEmailToProjectID(cfg.Email)
+	if err != nil {
+		return nil, err
+	}
+	s := &gcpState{
+		JWTConfig: cfg,
+		ProjectID: projectID,
+	}
+	if !s.APIsEnabled {
+		if err := s.enableAPIs(); err != nil {
+			return nil, err
+		}
+		s.APIsEnabled = true
+	}
+	if err := s.save(); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+// serviceAccountEmailToProjectID takes a service account email address and
+// extracts the project ID component from its domain part.
+func serviceAccountEmailToProjectID(email string) (string, error) {
+	i := strings.Index(email, "@")
+	if i < 0 {
+		return "", fmt.Errorf("service account email %q has no @ sign", email)
+	}
+	const domain = ".iam.gserviceaccount.com"
+	if !strings.HasSuffix(email, domain) {
+		return "", fmt.Errorf("service account email %q does not have expected form", email)
+	}
+	return email[i+1 : len(email)-len(domain)], nil
+}
+
+// enableAPIs enables the Compute, Storage, and IAM APIs required to deploy
+// upspinserver to GCP.
+func (s *gcpState) enableAPIs() error {
+	client := s.JWTConfig.Client(context.Background())
+	svc, err := servicemanagement.New(client)
+	if err != nil {
+		return err
+	}
+	apis := []string{
+		"compute_component",  // For the virtual machine.
+		"storage_api",        // For storage bucket.
+		"iam.googleapis.com", // For creating a service account.
+	}
+	for _, api := range apis {
+		if err := s.enableAPI(api, svc); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// enableAPI enables the named GCP API using the provided service.
+func (s *gcpState) enableAPI(name string, svc *servicemanagement.APIService) error {
+	op, err := svc.Services.Enable(name, &servicemanagement.EnableServiceRequest{ConsumerId: "project:" + s.ProjectID}).Do()
+	if err != nil {
+		return err
+	}
+	for !op.Done {
+		op, err = svc.Operations.Get(op.Name).Do()
+		if err != nil {
+			return err
+		}
+	}
+	if op.Error != nil {
+		return errors.New(op.Error.Message)
+	}
+	return err
+}
+
+// create creates a Storage bucket with the given name, a service account to
+// access the bucket, and a Compute instance running on a static IP address.
+func (s *gcpState) create(bucketName string) error {
+	// TODO: make these user-configurable.
+	s.Region = "us-central1"
+	s.Zone = "us-central1-a"
+	s.MachineType = "n1-standard-1"
+
+	if s.Storage.ServiceAccount == "" {
+		email, key, err := s.createServiceAccount()
+		if err != nil {
+			return err
+		}
+		s.Storage.ServiceAccount = email
+		s.Storage.PrivateKeyData = key
+	}
+	if err := s.save(); err != nil {
+		return err
+	}
+	if s.Storage.Bucket == "" {
+		err := s.createBucket(bucketName)
+		if err != nil {
+			return err
+		}
+		s.Storage.Bucket = bucketName
+	}
+	if err := s.save(); err != nil {
+		return err
+	}
+	if s.Server.IPAddr == "" {
+		ip, err := s.createAddress()
+		if err != nil {
+			return err
+		}
+		s.Server.IPAddr = ip
+	}
+	if err := s.save(); err != nil {
+		return err
+	}
+	if !s.Server.Created {
+		err := s.createInstance()
+		if err != nil {
+			return err
+		}
+		s.Server.Created = true
+	}
+	return s.save()
+}
+
+// createAddress reserves a static IP address with the name "upspinserver".
+func (s *gcpState) createAddress() (ip string, err error) {
+	client := s.JWTConfig.Client(context.Background())
+	svc, err := compute.New(client)
+	if err != nil {
+		return "", err
+	}
+
+	const addressName = "upspinserver"
+	addr := &compute.Address{
+		Description: "Public IP address for upspinserver",
+		Name:        addressName,
+	}
+	op, err := svc.Addresses.Insert(s.ProjectID, s.Region, addr).Do()
+	if err = okReason("alreadyExists", s.waitOp(svc, op, err)); err != nil {
+		return "", err
+	}
+	addr, err = svc.Addresses.Get(s.ProjectID, s.Region, addressName).Do()
+	if err != nil {
+		return "", err
+	}
+	return addr.Address, nil
+}
+
+// createInstance creates a Compute instance named "upspinserver" running the
+// upspinserver Docker image and a firewall rule to allow HTTPS connections to
+// that instance. If a firewall rule of the name "allow-https" exists it is
+// re-used.
+func (s *gcpState) createInstance() error {
+	client := s.JWTConfig.Client(context.Background())
+	svc, err := compute.New(client)
+	if err != nil {
+		return err
+	}
+
+	// TODO: make these configurable?
+	const (
+		firewallName = "allow-https"
+		firewallTag  = firewallName
+
+		instanceName = "upspinserver"
+	)
+
+	// Create a firewall to permit HTTPS connections.
+	firewall := &compute.Firewall{
+		Allowed: []*compute.FirewallAllowed{{
+			IPProtocol: "tcp",
+			Ports:      []string{"443"},
+		}},
+		Description:  "Allow HTTPS",
+		Name:         firewallName,
+		SourceRanges: []string{"0.0.0.0/0"},
+		TargetTags:   []string{firewallTag},
+	}
+	op, err := svc.Firewalls.Insert(s.ProjectID, firewall).Do()
+	if err = okReason("alreadyExists", s.waitOp(svc, op, err)); err != nil {
+		return err
+	}
+
+	// Create a firewall to permit HTTPS connections.
+	// Create the instance.
+	userData := cloudInitYAML
+	instance := &compute.Instance{
+		Description: "upspinserver instance",
+		Disks: []*compute.AttachedDisk{{
+			AutoDelete: true,
+			Boot:       true,
+			DeviceName: "upspinserver",
+			InitializeParams: &compute.AttachedDiskInitializeParams{
+				SourceImage: "projects/cos-cloud/global/images/family/cos-stable",
+			},
+		}},
+		MachineType: "zones/" + s.Zone + "/machineTypes/" + s.MachineType,
+		Name:        instanceName,
+		Tags:        &compute.Tags{Items: []string{firewallTag}},
+		Metadata: &compute.Metadata{
+			Items: []*compute.MetadataItems{{
+				Key:   "user-data",
+				Value: &userData,
+			}},
+		},
+		NetworkInterfaces: []*compute.NetworkInterface{{
+			AccessConfigs: []*compute.AccessConfig{{
+				NatIP: s.Server.IPAddr,
+			}},
+		}},
+	}
+	op, err = svc.Instances.Insert(s.ProjectID, s.Zone, instance).Do()
+	return s.waitOp(svc, op, err)
+}
+
+// createServiceAccount creates a service account named "upspinstorage" and
+// generates a JSON Private Key for authenticating as that account.
+func (s *gcpState) createServiceAccount() (email, privateKeyData string, err error) {
+	client := s.JWTConfig.Client(context.Background())
+	svc, err := iam.New(client)
+	if err != nil {
+		return "", "", err
+	}
+
+	name := "projects/" + s.ProjectID
+	req := &iam.CreateServiceAccountRequest{
+		AccountId: "upspinstorage",
+		ServiceAccount: &iam.ServiceAccount{
+			DisplayName: "Upspin Storage",
+		},
+	}
+	acct, err := svc.Projects.ServiceAccounts.Create(name, req).Do()
+	if isExists(err) {
+		// This should be the name we need to get.
+		// TODO(adg): make this more robust by listing instead.
+		guess := name + "/serviceAccounts/upspinstorage@" + s.ProjectID + ".iam.gserviceaccount.com"
+		acct, err = svc.Projects.ServiceAccounts.Get(guess).Do()
+	}
+	if err != nil {
+		return "", "", err
+	}
+
+	name += "/serviceAccounts/" + acct.Email
+	req2 := &iam.CreateServiceAccountKeyRequest{}
+	key, err := svc.Projects.ServiceAccounts.Keys.Create(name, req2).Do()
+	if err != nil {
+		return "", "", err
+	}
+	return acct.Email, key.PrivateKeyData, nil
+}
+
+// createBucket creates the named Storage bucket, giving "owner" access to
+// Storage.ServiceAccount in gcpState.
+func (s *gcpState) createBucket(bucket string) error {
+	client := s.JWTConfig.Client(context.Background())
+	svc, err := storage.New(client)
+	if err != nil {
+		return err
+	}
+
+	_, err = svc.Buckets.Insert(s.ProjectID, &storage.Bucket{
+		Acl: []*storage.BucketAccessControl{{
+			Bucket: bucket,
+			Entity: "user-" + s.Storage.ServiceAccount,
+			Email:  s.Storage.ServiceAccount,
+			Role:   "OWNER",
+		}},
+		Name: bucket,
+		// TODO(adg): region
+	}).Do()
+	if isExists(err) {
+		// Bucket already exists.
+		// TODO(adg): update bucket ACL to make sure the service
+		// account has access. (For now, we assume that the user
+		// created the bucket using this command and that the bucket
+		// has the correct permissions.)
+		return nil
+	}
+	return err
+}
+
+// waitOp waits for the given compute operation to complete and returns the
+// first error that occurred, if any.
+func (s *gcpState) waitOp(svc *compute.Service, op *compute.Operation, err error) error {
+	for err == nil && (op.Status == "PENDING" || op.Status == "RUNNING") {
+		time.Sleep(250 * time.Millisecond)
+		switch {
+		case op.Zone != "":
+			op, err = svc.ZoneOperations.Get(s.ProjectID, s.Zone, op.Name).Do()
+		case op.Region != "":
+			op, err = svc.RegionOperations.Get(s.ProjectID, s.Region, op.Name).Do()
+		default:
+			op, err = svc.GlobalOperations.Get(s.ProjectID, op.Name).Do()
+		}
+	}
+	return opError(op, err)
+}
+
+// opError returns err or the first error in the given Operation, if any.
+func opError(op *compute.Operation, err error) error {
+	if err != nil {
+		return err
+	}
+	if op == nil || op.Error == nil || len(op.Error.Errors) == 0 {
+		return nil
+	}
+	return errors.New(op.Error.Errors[0].Message)
+}
+
+// isExists reports whether err is an "already exists" Google API error.
+func isExists(err error) bool {
+	return err != nil && (okReason("alreadyExists", err) == nil || okReason("conflict", err) == nil)
+}
+
+// okReason checks whether err is a Google API error with the given reason and
+// returns nil if so. Otherwise, it returns the given error.
+func okReason(reason string, err error) error {
+	if ge, ok := err.(*googleapi.Error); ok && len(ge.Errors) > 0 {
+		for _, e := range ge.Errors {
+			if e.Reason != reason {
+				return err
+			}
+		}
+		return nil
+	}
+	return err
+}
+
+// configureServer configures an unconfigured upspinserver instance using
+// the state from gcpState and the given set of writers.
+// It is analagous to running "upspin setupserver".
+//
+// TODO(adg): this needn't be a method on gcpState as it's not at all to do
+// with GCP. Move it elsewhere if/when we decide to generalize this to support
+// other cloud service providers.
+func (s *gcpState) configureServer(writers []upspin.UserName) error {
+	files := map[string][]byte{}
+
+	var buf bytes.Buffer
+	for _, u := range writers {
+		fmt.Fprintln(&buf, u)
+	}
+	files["Writers"] = buf.Bytes()
+
+	for _, name := range []string{"public.upspinkey", "secret.upspinkey"} {
+		b, err := ioutil.ReadFile(filepath.Join(s.Server.KeyDir, name))
+		if err != nil {
+			return err
+		}
+		files[name] = b
+	}
+
+	scfg := s.serverConfig()
+	b, err := json.Marshal(scfg)
+	if err != nil {
+		return err
+	}
+	files["serverconfig.json"] = b
+
+	b, err = json.Marshal(files)
+	if err != nil {
+		return err
+	}
+	u := "https://" + string(scfg.Addr) + "/setupserver"
+	resp, err := http.Post(u, "application/octet-stream", bytes.NewReader(b))
+	if err != nil {
+		return err
+	}
+	b, _ = ioutil.ReadAll(resp.Body)
+	resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("upspinserver returned status %v:\n%s", resp.Status, b)
+	}
+	return nil
+}
+
+// cloudInitYAML is the cloud-init configuration file for the virtual machine
+// running Google's Container-Optimized OS. It instructs the machine to accept
+// incoming TCP connections on port 443 and to run the
+// gcr.io/upspin-containers-upspinserver Docker image, exposing the
+// upspinserver service on port 443.
+const cloudInitYAML = `#cloud-config
+
+users:
+- name: upspin
+  uid: 2000
+
+runcmd:
+- iptables -w -A INPUT -p tcp --dport 443 -j ACCEPT
+
+write_files:
+- path: /etc/systemd/system/upspinserver.service
+  permissions: 0644
+  owner: root
+  content: |
+    [Unit]
+    Description=An upspinserver container instance
+    Wants=gcr-online.target
+    After=gcr-online.target
+    [Service]
+    Environment="HOME=/home/upspin"
+    ExecStartPre=/usr/bin/docker-credential-gcr configure-docker
+    ExecStart=/usr/bin/docker run --rm -u=2000 --volume=/home/upspin:/upspin -p=443:8443 --name=upspinserver gcr.io/upspin-containers/upspinserver:latest
+    ExecStop=/usr/bin/docker stop upspinserver
+    ExecStopPost=/usr/bin/docker rm upspinserver
+
+runcmd:
+- systemctl daemon-reload
+- systemctl start upspinserver.service
+
+`
diff --git a/cmd/browser/startup.go b/cmd/browser/startup.go
index 253aecb..d227bf0 100644
--- a/cmd/browser/startup.go
+++ b/cmd/browser/startup.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// TODO(adg): Support automatically provisioning an upspinserver instance.
-
 package main
 
 import (
@@ -16,7 +14,9 @@
 	"os/exec"
 	"path/filepath"
 	"reflect"
+	"sort"
 	"strings"
+	"time"
 
 	"upspin.io/bind"
 	"upspin.io/client"
@@ -27,6 +27,7 @@
 	"upspin.io/key/usercache"
 	"upspin.io/serverutil/signup"
 	"upspin.io/upspin"
+	"upspin.io/user"
 	"upspin.io/valid"
 )
 
@@ -48,12 +49,27 @@
 	// It may be one of "startup", "secretseed", or "verify".
 	Step string
 
-	// Step: "secretseed"
+	// Step: "secretseed" and "serverSecretseed"
 	KeyDir     string
 	SecretSeed string
 
 	// Step: "verify"
 	UserName upspin.UserName
+
+	// Step: "gcpDetails"
+	BucketName string
+	// TODO: region, zone, instance size
+
+	// Step: "serverUserName"
+	UserNamePrefix string // Includes trailing "+".
+	UserNameSuffix string // Suggested default.
+	UserNameDomain string // Includes leading "@".
+
+	// Step: "serverHostName"
+	IPAddr string
+
+	// Step: "serverWriters"
+	Writers []upspin.UserName
 }
 
 // startup populates s.cfg and s.cli by either loading the config file
@@ -68,10 +84,27 @@
 //  - Check that the config's user exists on the Key Server. If not:
 //    - Prompt the user to click the verification link in the email (Step: "verify").
 //  - Check that the user has endpoints defined in the config file. If not:
-//    - Prompt the user to choose dir/store endpoints, or none. (Step: "serverSelect")
-//      TODO(adg): GCP project creation
-//    - Update the user's endpoints in the keyserver record.
-//    - Rewrite the config file with the chosen endpoints.
+//    - Prompt the user to choose dir/store endpoints, deploy to GCP, or none.
+//      (Step: "serverSelect")
+//    - If the user selects explicit dir/store endpoints, or none:
+//      - Update the user's endpoints in the keyserver record.
+//      - Rewrite the config file with the chosen endpoints.
+//    - If the user selects GCP deployment, do all of this:
+//      - Prompt user to create a GCP Project and service account ("serverGCP").
+//      - Use the provided service account key to authenticate with GCP and
+//        enable the relevant APIs.
+//      - Prompt user for GCP details such as bucket name and region ("gcpDetails").
+//      - Create the GCP Storage Bucket, Address, and Compute Instance.
+//      - Prompt user for a user name for the server ("serverUserName").
+//      - Register the server user name with the key server.
+//      - Display server user proquint, ask user to write it down ("serverSecretseed").
+//      - Prompt user for a host name for the server ("serverHostName").
+//      - If they elect for a default, create a host name through host@upspin.io.
+//      - Check that the host name resolves to the server IP.
+//      - Update the key server records for both the user and server user so that
+//        the directory endpoint is the new server.
+//      - Prompt the user to specify server Writers ("serverWriters").
+//      - Configure the running upspinserver by sending a configuration request.
 //
 // Only one of startup's return values should be non-nil. If a user is to be
 // presented with a given step, startup returns a non-nil startupResponse. If
@@ -91,9 +124,17 @@
 	if action == "signup" {
 		// The user clicked the "Sign up" button on the signup dialog.
 		userName := upspin.UserName(req.FormValue("username"))
+
 		if err := valid.UserName(userName); err != nil {
 			return nil, nil, err
 		}
+		_, suffix, _, err := user.Parse(userName)
+		if err != nil {
+			return nil, nil, err
+		}
+		if suffix != "" {
+			return nil, nil, errors.Errorf("Your primary user name must not contain a + symbol.")
+		}
 
 		// Check whether userName already exists on the KeyServer.
 		userCfg := config.SetUserName(config.New(), userName)
@@ -104,7 +145,7 @@
 		}
 
 		// Write config file.
-		err := writeConfig(flags.Config, userName, upspin.Endpoint{}, upspin.Endpoint{}, false)
+		err = writeConfig(flags.Config, userName, upspin.Endpoint{}, upspin.Endpoint{}, false)
 		if err != nil {
 			return nil, nil, err
 		}
@@ -129,19 +170,27 @@
 			Step: "signup",
 		}, nil, nil
 	}
+
+	// Load existing config file.
 	cfg, err := config.FromFile(flags.Config)
 	if err != nil {
 		return nil, nil, err
 	}
 
+	// Check for and load GCP setup state file.
+	st, err := gcpStateFromFile()
+	if err != nil && !os.IsNotExist(err) {
+		return nil, nil, err
+	}
+
+	var response string
 	switch action {
 	case "register":
 		if err := signup.MakeRequest(signupURL, cfg); err != nil {
 			if keyDir != "" {
 				// We have just generated the keys, so we
 				// should remove both the keys and the config,
-				// since they are bad. TODO(adg): really think
-				// about this carefully!
+				// since they are bad.
 				os.RemoveAll(keyDir)
 				os.Remove(flags.Config)
 			}
@@ -165,47 +214,31 @@
 		if err != nil {
 			return nil, nil, errors.Errorf("invalid hostname %q: %v", dirHost, err)
 		}
+		cfg = config.SetDirEndpoint(cfg, dirEndpoint)
 		storeHost := req.FormValue("storeServer")
 		storeEndpoint, err := hostnameToEndpoint(storeHost)
 		if err != nil {
 			return nil, nil, errors.Errorf("invalid hostname %q: %v", storeHost, err)
 		}
+		cfg = config.SetStoreEndpoint(cfg, storeEndpoint)
 
-		dir, err := bind.DirServer(cfg, dirEndpoint)
-		if err != nil {
-			return nil, nil, errors.Errorf("could not find %q:\n%v", dirHost, err)
-		}
+		// Check that the StoreServer is up.
 		store, err := bind.StoreServer(cfg, storeEndpoint)
 		if err != nil {
 			return nil, nil, errors.Errorf("could not find %q:\n%v", storeHost, err)
 		}
-
-		// Check that the StoreServer is up.
 		_, _, _, err = store.Get("Upspin:notexist")
 		if err != nil && !errors.Match(errors.E(errors.NotExist), err) {
 			return nil, nil, errors.Errorf("error communicating with %q:\n%v", storeHost, err)
 		}
-		cfg = config.SetStoreEndpoint(cfg, storeEndpoint)
 
 		// Check that the DirServer is up, and create the user root.
-		makeRoot := false
-		root := upspin.PathName(cfg.UserName())
-		_, err = dir.Lookup(root)
-		if errors.Match(errors.E(errors.NotExist), err) {
-			makeRoot = true
-		} else if err != nil {
-			return nil, nil, errors.Errorf("error communicating with %q:\n%v", dirHost, err)
-		}
-		cfg = config.SetDirEndpoint(cfg, dirEndpoint)
-		if makeRoot {
-			_, err = client.New(cfg).MakeDirectory(root)
-			if err != nil {
-				return nil, nil, errors.Errorf("error creating Upspin root:\n%v", err)
-			}
+		if err := makeRoot(cfg); err != nil {
+			return nil, nil, err
 		}
 
 		// Put the updated user record to the key server.
-		if err := putUser(cfg); err != nil {
+		if err := putUser(cfg, nil); err != nil {
 			return nil, nil, errors.Errorf("error updating key server:\n%v", err)
 		}
 
@@ -224,27 +257,294 @@
 		if err != nil {
 			return nil, nil, err
 		}
+
+	case "specifyGCP":
+		privateKeyData := req.FormValue("privateKeyData")
+
+		st, err = gcpStateFromPrivateKeyJSON([]byte(privateKeyData))
+		if err != nil {
+			return nil, nil, err
+		}
+
+		response = "gcpDetails"
+
+	case "createGCP":
+		bucketName := req.FormValue("bucketName")
+
+		// Create the bucket, VM instance, and other associated bits.
+		if err := st.create(bucketName); err != nil {
+			return nil, nil, err
+		}
+
+		response = "serverUserName"
+
+	case "configureServerUserName":
+		suffix := req.FormValue("userNameSuffix")
+
+		username, _, domain, err := user.Parse(cfg.UserName())
+		if err != nil {
+			return nil, nil, err
+		}
+		serverUser, err := user.Clean(upspin.UserName(username + "+" + suffix + "@" + domain))
+		if err != nil {
+			return nil, nil, err
+		}
+
+		// Generate key.
+		seed, keyDir, err := keygen(serverUser)
+		if err != nil {
+			return nil, nil, err
+		}
+		// Write config file.
+		serverCfgFile := flags.Config + "." + suffix
+		err = writeConfig(serverCfgFile, serverUser, upspin.Endpoint{}, upspin.Endpoint{}, false)
+		if err != nil {
+			os.RemoveAll(keyDir)
+			return nil, nil, err
+		}
+		// Read config file back.
+		serverCfg, err := config.FromFile(serverCfgFile)
+		if err != nil {
+			os.RemoveAll(keyDir)
+			os.Remove(serverCfgFile)
+			return nil, nil, err
+		}
+		// Put the server user to the key server.
+		if err := putUser(cfg, serverCfg); err != nil {
+			os.RemoveAll(keyDir)
+			os.Remove(serverCfgFile)
+			return nil, nil, err
+		}
+
+		// Save the state.
+		st.Server.KeyDir = keyDir
+		st.Server.UserName = serverUser
+		if err := st.save(); err != nil {
+			return nil, nil, err
+		}
+
+		return &startupResponse{
+			Step:       "serverSecretseed",
+			SecretSeed: seed,
+			KeyDir:     keyDir,
+		}, nil, nil
+
+	case "configureServerHostName":
+		hostName := req.FormValue("hostName")
+
+		// Load server user and config for use by serviceHostName and later.
+		serverUser := st.Server.UserName
+		_, serverSuffix, _, _ := user.Parse(serverUser)
+		serverCfgFile := flags.Config + "." + serverSuffix
+		serverCfg, err := config.FromFile(serverCfgFile)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		// Set up a default host name if none provided.
+		if hostName == "" {
+			hostName, err = serviceHostName(serverCfg, st.Server.IPAddr)
+			if err != nil {
+				return nil, nil, err
+			}
+			// Wait a few seconds before calling hostResolvesTo so
+			// that the change has a chance to propagate. This may
+			// not be long enough in some configurations.
+			// TODO(adg): More testing is required.
+			time.Sleep(15 * time.Second)
+		}
+
+		// Check that the host name resolves to what we expect.
+		if err := hostResolvesTo(hostName, st.Server.IPAddr); err != nil {
+			return nil, nil, err
+		}
+		ep, err := hostnameToEndpoint(hostName)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		// Update the user config file and key server record.
+		cfg = config.SetDirEndpoint(cfg, ep)
+		cfg = config.SetStoreEndpoint(cfg, ep)
+		if err := writeConfig(flags.Config, cfg.UserName(), ep, ep, true); err != nil {
+			return nil, nil, err
+		}
+		if err := putUser(cfg, nil); err != nil {
+			return nil, nil, err
+		}
+
+		// Update the server user config and key server record.
+		serverCfg = config.SetDirEndpoint(serverCfg, ep)
+		serverCfg = config.SetStoreEndpoint(serverCfg, ep)
+		if err := writeConfig(serverCfgFile, serverUser, ep, ep, true); err != nil {
+			return nil, nil, err
+		}
+		if err := putUser(cfg, serverCfg); err != nil {
+			return nil, nil, err
+		}
+
+		// Save the state.
+		st.Server.HostName = hostName
+		if err := st.save(); err != nil {
+			return nil, nil, err
+		}
+
+		response = "serverWriters"
+
+	case "configureServer":
+		writersList := req.FormValue("writers")
+
+		// Gather list of writers.
+		// Always include the user and the server user.
+		w := map[upspin.UserName]bool{
+			st.Server.UserName: true,
+			cfg.UserName():     true,
+		}
+		for _, s := range strings.Fields(writersList) {
+			name := upspin.UserName(s)
+			if name == "" {
+				continue
+			}
+			var err error
+			name, err = user.Clean(name)
+			if err != nil {
+				return nil, nil, err
+			}
+			w[name] = true
+		}
+		var writers []upspin.UserName
+		for name := range w {
+			writers = append(writers, name)
+		}
+		sort.Slice(writers, func(i, j int) bool { return writers[i] < writers[j] })
+
+		// Configure the upspinserver.
+		if err := st.configureServer(writers); err != nil {
+			return nil, nil, err
+		}
+
+		// Save the state.
+		st.Server.Configured = true
+		if err := st.save(); err != nil {
+			return nil, nil, err
+		}
+		// TODO: delete state file instead of saving?
+	}
+
+	// If we're in the middle of setting up a GCP instance, prompt the user
+	// with the correct step of the process. Otherwise, if the user has not
+	// specified an endpoint (including 'unassigned') in the config file,
+	// prompt them to select Upspin servers.
+	if st != nil && response == "" {
+		// Deploying to GCP...
+		if st.APIsEnabled {
+			response = "gcpDetails"
+		}
+		if st.Server.Created {
+			response = "serverUserName"
+		}
+		if st.Server.UserName != "" {
+			response = "serverHostName"
+		}
+		if st.Server.HostName != "" {
+			response = "serverWriters"
+		}
+		if st.Server.Configured {
+			response = ""
+		}
+	} else if response == "" && cfg.DirEndpoint() == (upspin.Endpoint{}) {
+		ok, err := hasEndpoints(flags.Config)
+		if err != nil {
+			return nil, nil, err
+		}
+		if !ok {
+			return &startupResponse{
+				Step: "serverSelect",
+			}, nil, nil
+		}
+	}
+
+	switch response {
+	case "gcpDetails":
+		// Prompt for GCP Details such as bucket name and eventually
+		// GCP zone/region, instance size, etc.
+
+		// Assume a resonable default bucket name dervied from the project ID.
+		bucketName := st.ProjectID + "-upspin"
+		// TODO: check bucketName is available
+
+		return &startupResponse{
+			Step:       "gcpDetails",
+			BucketName: bucketName,
+		}, nil, nil
+
+	case "serverUserName":
+		// Prompt for a user name suffix for the server.
+		// Provide a reasonable default suffix, 'upspinserver'.
+		// If the user name already contains 'upspin' then suggest just
+		// 'server' to avoid stutter.
+
+		// Split the user name into user and domain components, to
+		// display to the user as they choose the suffix.
+		user, suffix, domain, err := user.Parse(cfg.UserName())
+		if err != nil {
+			return nil, nil, err
+		}
+		if suffix != "" {
+			// Sanity check only; we shouldn't get here.
+			return nil, nil, errors.Errorf("user name %q should not contain a + symbol", user)
+		}
+
+		// Choose default suffix.
+		suffix = "upspinserver"
+		if strings.Contains(user, "upspin") {
+			suffix = "server"
+		}
+
+		return &startupResponse{
+			Step:           "serverUserName",
+			UserNamePrefix: user + "+",
+			UserNameSuffix: suffix,
+			UserNameDomain: "@" + domain,
+		}, nil, nil
+
+	case "serverHostName":
+		// Prompt for a host name.
+		// The default is an assigned name under upspin.services.
+		return &startupResponse{
+			Step:   "serverHostName",
+			IPAddr: st.Server.IPAddr,
+		}, nil, nil
+
+	case "serverWriters":
+		// Prompt for a list of server Writers.
+		// Pre-populate the list with the server user name
+		// and the active user, so they get the idea.
+		// Those users will *always* be added to the list, though.
+
+		return &startupResponse{
+			Step: "serverWriters",
+			Writers: []upspin.UserName{
+				st.Server.UserName,
+				cfg.UserName(),
+			},
+		}, nil, nil
 	}
 
 	// Is the user now registered with the KeyServer?
 	if ok, err := isRegistered(cfg); err != nil {
 		return nil, nil, err
 	} else if !ok {
-		// TODO: Read seed from secret.upspinkey?
+		// TODO: Read seed from secret.upspinkey
+		// and display proquint again?
 		return &startupResponse{
 			Step:     "verify",
 			UserName: cfg.UserName(),
 		}, nil, nil
 	}
 
-	// If the user has not specified an endpoint (including 'unassigned')
-	// in their config file, prompt them to select Upspin servers.
-	if ok, err := hasEndpoints(flags.Config); err != nil {
+	if err := makeRoot(cfg); err != nil {
 		return nil, nil, err
-	} else if !ok && cfg.DirEndpoint() == (upspin.Endpoint{}) {
-		return &startupResponse{
-			Step: "serverSelect",
-		}, nil, nil
 	}
 
 	// We have a valid config. Set it in the server struct so that the
@@ -262,6 +562,7 @@
 // keygen runs 'upspin keygen', placing keys in the default directory for the
 // given user. It returns the secret seed for the keys and the key directory.
 // If the default key directory already exists, keygen return an error.
+// TODO(adg): replace this with native Go code, instead of calling the upspin command.
 func keygen(user upspin.UserName) (seed, keyDir string, err error) {
 	keyDir, err = config.DefaultSecretsDir(user)
 	if err != nil {
@@ -309,7 +610,9 @@
 		cfg += fmt.Sprintf("storeserver: %s\n", store)
 	}
 	cfg += "packing: ee\n"
-	cfg += "cache: yes\n" // TODO(adg): make this configurable?
+	// Deactivated cache for now, as it seems to interact poorly with
+	// host@upspin.io. TODO(adg): turn it back on after more testing.
+	//cfg += "cache: yes\n" // TODO(adg): make this configurable?
 	return ioutil.WriteFile(file, []byte(cfg), 0644)
 }
 
@@ -331,17 +634,48 @@
 	return true, nil
 }
 
-// putUser updates the key server with the user name, endpoints, and public key
-// in the given config.
-func putUser(cfg upspin.Config) error {
-	f := cfg.Factotum()
+// makeRoot checks whether the given config's user's root exists and creates it
+// if not, returning any unexpected errors that occur during the process.
+func makeRoot(cfg upspin.Config) error {
+	make := false
+	addr := cfg.DirEndpoint().NetAddr
+	root := upspin.PathName(cfg.UserName())
+	dir, err := bind.DirServer(cfg, cfg.DirEndpoint())
+	if err != nil {
+		return errors.Errorf("could not find %q:\n%v", addr, err)
+	}
+	_, err = dir.Lookup(root)
+	if errors.Match(errors.E(errors.NotExist), err) {
+		make = true
+	} else if err != nil {
+		return errors.Errorf("error communicating with %q:\n%v", addr, err)
+	}
+	if !make {
+		return nil
+	}
+	_, err = client.New(cfg).MakeDirectory(root)
+	if err != nil {
+		return errors.Errorf("error creating Upspin root:\n%v", err)
+	}
+	return nil
+}
+
+// putUser updates the key server as the user in cfg with the user name,
+// endpoints, and public key in the userCfg.
+// If userCfg is nil then cfg is used in its place.
+func putUser(cfg, userCfg upspin.Config) error {
+	if userCfg == nil {
+		userCfg = cfg
+	}
+
+	f := userCfg.Factotum()
 	if f == nil {
-		return errors.E(cfg.UserName(), errors.Str("user has no keys"))
+		return errors.E(userCfg.UserName(), errors.Str("user has no keys"))
 	}
 	newU := upspin.User{
-		Name:      cfg.UserName(),
-		Dirs:      []upspin.Endpoint{cfg.DirEndpoint()},
-		Stores:    []upspin.Endpoint{cfg.StoreEndpoint()},
+		Name:      userCfg.UserName(),
+		Dirs:      []upspin.Endpoint{userCfg.DirEndpoint()},
+		Stores:    []upspin.Endpoint{userCfg.StoreEndpoint()},
 		PublicKey: f.PublicKey(),
 	}
 
@@ -350,17 +684,62 @@
 		return err
 	}
 	usercache.ResetGlobal() // Avoid hitting the local user cache.
-	oldU, err := key.Lookup(cfg.UserName())
-	if err != nil {
+	oldU, err := key.Lookup(userCfg.UserName())
+	if err != nil && !errors.Match(errors.E(errors.NotExist), err) {
 		return err
 	}
-	if reflect.DeepEqual(*oldU, newU) {
+	if reflect.DeepEqual(oldU, &newU) {
 		// Don't do anything if we're not changing anything.
 		return nil
 	}
 	return key.Put(&newU)
 }
 
+// serviceHostName registers an upspin.services host name with host@upspin.io
+// for the given config's user and configures it to resolve to the given IP
+// address.
+func serviceHostName(cfg upspin.Config, ip string) (string, error) {
+	cli := client.New(cfg)
+	base := upspin.PathName("host@upspin.io/" + cfg.UserName())
+	_, err := cli.MakeDirectory(base + "/" + upspin.PathName(ip))
+	if err != nil {
+		return "", err
+	}
+	b, err := cli.Get(base)
+	if err != nil {
+		return "", err
+	}
+	p := bytes.SplitN(b, []byte("\n"), 2)
+	if len(p) == 2 {
+		host := string(bytes.TrimSpace(p[1]))
+		if strings.HasSuffix(host, "upspin.services") {
+			return host, nil
+		}
+	}
+	return "", errors.Errorf("unexpected response from host@upspin.io:\n%s", b)
+}
+
+// hostResolvesTo checks whether the given host name resolves to the given IP
+// address. If not, it returns a descriptive error.
+func hostResolvesTo(host, ip string) error {
+	// TODO(adg): provide different error messages when upspin.services is
+	// in the host name. In those cases, maybe the DNS cache is stale or
+	// they might be misusing the service.
+	ips, err := net.LookupIP(host)
+	if err != nil {
+		return errors.Errorf("Could not resolve %q:\n%s", host, err)
+	}
+	if len(ips) == 0 {
+		return errors.Errorf("The host %q does not resolve to any IP.\nIt should resolve to %q. Check your DNS settings.", host, ip)
+	}
+	for _, ipp := range ips {
+		if ipp.String() != ip {
+			return errors.Errorf("The host %q resolves to %q.\nIt should resolve to %q. Check your DNS settings.", host, ipp, ip)
+		}
+	}
+	return nil
+}
+
 // hasEndpoints reports whether the given config file contains a dirserver
 // endpoint. It is only a rough test (it doesn't actually parse the YAML) and
 // should be used in concert with a check against the parsed config.
@@ -372,11 +751,14 @@
 	return bytes.Contains(b, []byte("\ndirserver:")), nil
 }
 
+// exists reports whether the given path is accessible.
 func exists(path string) bool {
 	_, err := os.Stat(path)
 	return err == nil
 }
 
+// hostnameToEndpoint returns the remote endpoint for the given host name,
+// appending :443 if no port is provided.
 func hostnameToEndpoint(hostname string) (upspin.Endpoint, error) {
 	if !strings.Contains(hostname, ":") {
 		hostname += ":443"
diff --git a/cmd/browser/static/index.html b/cmd/browser/static/index.html
index 16212b7..8bd23b0 100644
--- a/cmd/browser/static/index.html
+++ b/cmd/browser/static/index.html
@@ -325,6 +325,232 @@
   </div>
 </div>
 
+<!-- serverGCP modal -->
+
+<div class="modal fade up-serverGCP" tabindex="-1" data-backdrop="static" data-keyboard="false">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h4 class="modal-title">Create a Google Cloud Platform project</h4>
+      </div>
+      <div class="modal-body">
+		<p>
+		To deploy an Upsipn server to the Google Cloud Platform,
+		follow these steps:
+		</p>
+		<ol>
+			<li><a href="https://console.cloud.google.com/project" target="_blank">Create a project</a>
+				(a collection of services: virtual machines,
+				storage, and so on),
+			</li>
+			<li><a href="https://support.google.com/cloud/answer/6293499#enable-billing" target="_blank">Enable billing</a>
+				for the project,</li>
+			<li><a href="https://console.cloud.google.com/iam-admin/serviceaccounts/project" target="_blank">Create a Service Account</a>, with these properties:
+				<ul>
+					<li>Set the <b>Name</b> to <b>"Owner"</b>.</li>
+					<li>Set the <b>Role</b> to <b>"Project Owner"</b>.</li>
+					<li>Furnish a new <b>JSON Private Key</b>.</li>
+				</ul>
+			</li>
+			<li>Upload the private key file using the form below.</li>
+		</ol>
+		<div class="form-group">
+			<label for="serverGCPKeyFile">JSON Private Key file:</label>
+			<input type="file" id="serverGCPKeyFile">
+		</div>
+		<div class="panel panel-danger up-error">
+			<div class="panel-heading">Error</div>
+			<div class="panel-body up-error-msg"></div>
+		</div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary">Continue</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- gcpDetails modal -->
+
+<div class="modal fade up-gcpDetails" tabindex="-1" data-backdrop="static" data-keyboard="false">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h4 class="modal-title">Specify Google Cloud Platform details</h4>
+      </div>
+      <div class="modal-body">
+		<p>
+		GCP particulars...
+		</p>
+		<div class="form-group">
+			<label for="gcpDetailsBucketName">Storage Bucket Name</label>
+			<input type="text" class="form-control" id="gcpDetailsBucketName" placeholder="Bucket name">
+		</div>
+		<p>
+		TODO: Storage region, Compute zone
+		</p>
+		<div class="panel panel-danger up-error">
+			<div class="panel-heading">Error</div>
+			<div class="panel-body up-error-msg"></div>
+		</div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary">Continue</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+
+<!-- serverUserName modal -->
+
+<div class="modal fade up-serverUserName" tabindex="-1" data-backdrop="static" data-keyboard="false">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h4 class="modal-title">Configure your server user</h4>
+      </div>
+      <div class="modal-body">
+		<p>
+		Specify a <b>user name</b> for the Upspin server, distinct to
+		your primary Upspin user. (The name is derived from your
+		current user name with the addition of a suffix to the user
+		name component.)
+		</p>
+		<p>
+		TODO: more about this
+		</p>
+		<div class="form-group form-inline">
+			<div class="input-group">
+				<div id="serverUserNamePrefix" class="input-group-addon"></div>
+				<input id="serverUserNameSuffix" type="text" class="form-control" placeholder="suffix">
+				<div id="serverUserNameDomain" class="input-group-addon"></div>
+			</div>
+		</div>
+		<div class="panel panel-danger up-error">
+			<div class="panel-heading">Error</div>
+			<div class="panel-body up-error-msg"></div>
+		</div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary">Continue</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- serverSecretseed modal -->
+
+<div class="modal fade up-serverSecretseed" tabindex="-1" data-backdrop="static" data-keyboard="false">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h4 class="modal-title">Write down your server's secret key</h4>
+      </div>
+      <div class="modal-body">
+		<p>
+		An Upspin key pair was generated for the server user 
+		<b class="up-username"></b>
+		and stored in this directory on your local machine:
+		</p>
+		<pre id="serverSecretseedKeyDir"></pre>
+		<p>
+		This key pair provides access to your server's Upspin identity
+		and data. If you lose the keys you can re-create them using
+		this "secret seed":
+		</p>
+		<pre id="serverSecretseedSecretSeed"></pre>
+		<p>
+		<b>Write this down and store it in a secure, private place.
+		Do not share your secret key or this string with anyone.</b>
+		</p>
+		<div class="panel panel-danger up-error">
+			<div class="panel-heading">Error</div>
+			<div class="panel-body up-error-msg"></div>
+		</div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-danger">OK, I wrote it down</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- serverHostName modal -->
+
+<div class="modal fade up-serverHostName" tabindex="-1" data-backdrop="static" data-keyboard="false">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h4 class="modal-title">Configure your server host name</h4>
+      </div>
+      <div class="modal-body">
+		<p>
+		Your Upspin server is running on a virtual machine at
+		<b class="up-ipAddr"></b> and now we must configure it
+		before it will be usable.
+		</p>
+		<p>
+		Specify a <b>host name</b> for your Upspin server.
+		This name is not typically user-visible, appearing only in your
+		key server record, configuration file, and in the metadata for
+		all Upspin content you write.
+		</p>
+		<p>
+		If you wish to use a host name under a domain you control,
+		create a DNS A record for that host name that points to
+		<b class="up-ipAddr"></b> and specify the host name in the
+		field below.
+		</p>
+		<p>
+		Otherwise, you may leave the field blank to use a default host
+		name provided for you under the domain <b>upspin.services</b>.
+		</p>
+		<div class="form-group">
+			<input type="text" class="form-control" id="serverHostName" placeholder="Host name (leave blank for default)">
+		</div>
+		<div class="panel panel-danger up-error">
+			<div class="panel-heading">Error</div>
+			<div class="panel-body up-error-msg"></div>
+		</div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary">Continue</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- serverWriters modal -->
+
+<div class="modal fade up-serverWriters" tabindex="-1" data-backdrop="static" data-keyboard="false">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h4 class="modal-title">Configure server access control</h4>
+      </div>
+      <div class="modal-body">
+		<p>
+		Your Upspin server is configured to allow writes by your user
+		and the server user. If you would like to allow other users to
+		store their directory trees and content in your Upspin server,
+		add them to this list:
+		</p>
+		<div class="form-group">
+			<textarea class="form-control" id="serverWriters" placeholder="user@example.com" rows=4></textarea>
+		</div>
+		<div class="panel panel-danger up-error">
+			<div class="panel-heading">Error</div>
+			<div class="panel-body up-error-msg"></div>
+		</div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary">Continue</button>
+      </div>
+    </div>
+  </div>
+</div>
+
 <!-- inspector modal -->
 
 <div class="modal fade up-inspector" tabindex="-1" role="dialog">
diff --git a/cmd/browser/static/script.js b/cmd/browser/static/script.js
index ac029c1..91e6956 100644
--- a/cmd/browser/static/script.js
+++ b/cmd/browser/static/script.js
@@ -346,6 +346,7 @@
 			show({Step: "serverExisting"});
 			break;
 		case $("#serverSelectGCP").is(":checked"):
+			show({Step: "serverGCP"});
 			break;
 		case $("#serverSelectNone").is(":checked"):
 			action({action: "specifyNoEndpoints"});
@@ -360,6 +361,57 @@
 			storeServer: $("#serverExistingStoreServer").val()
 		});
 	});
+	var serverGCPEl = $("body > .up-serverGCP");
+	serverGCPEl.find("button").click(function() {
+		var fileEl = $("#serverGCPKeyFile");
+		if (fileEl[0].files.length != 1) {
+			error("You must provide a JSON Private Key file.");
+			return;
+		}
+		var r = new FileReader();
+		r.onerror = function() {
+			error("An error occurred uploading the file.");
+		};
+		r.onload = function(state) {
+			action({
+				action: "specifyGCP",
+				privateKeyData: r.result
+			});
+		};
+		r.readAsText(fileEl[0].files[0]);
+	});
+	var gcpDetailsEl = $("body > .up-gcpDetails");
+	gcpDetailsEl.find("button").click(function() {
+		action({
+			action: "createGCP",
+			bucketName: $("#gcpDetailsBucketName").val()
+		});
+	});
+	var serverHostNameEl = $("body > .up-serverHostName");
+	serverHostNameEl.find("button").click(function() {
+		action({
+			action: "configureServerHostName",
+			hostName: $("#serverHostName").val()
+		});
+	});
+	var serverUserNameEl = $("body > .up-serverUserName");
+	serverUserNameEl.find("button").click(function() {
+		action({
+			action: "configureServerUserName",
+			userNameSuffix: $("#serverUserNameSuffix").val()
+		});
+	});
+	var serverSecretseedEl = $("body > .up-serverSecretseed");
+	serverSecretseedEl.find("button").click(function() {
+		show({Step: "serverHostName"});
+	});
+	var serverWritersEl = $("body > .up-serverWriters");
+	serverWritersEl.find("button").click(function() {
+		action({
+			action: "configureServer",
+			writers: $("#serverWriters").val()
+		});
+	});
 
 	var lastStep = "loading";
 	var lastEl = loadingEl;
@@ -384,27 +436,53 @@
 		switch (data.Step) {
 		case "signup":
 			lastEl = signupEl;
-			break
+			break;
 		case "secretseed":
 			$("#secretseedKeyDir").text(data.KeyDir);
 			$("#secretseedSecretSeed").text(data.SecretSeed);
 			lastEl = secretseedEl;
-			break
+			break;
 		case "verify":
 			verifyEl.find(".up-username").text(data.UserName);
 			lastEl = verifyEl;
-			break
+			break;
 		case "serverSelect":
 			lastEl = serverSelectEl;
-			break
+			break;
 		case "serverExisting":
 			lastEl = serverExistingEl;
-			break
+			break;
+		case "serverGCP":
+			lastEl = serverGCPEl;
+			break;
+		case "gcpDetails":
+			$("#gcpDetailsBucketName").val(data.BucketName);
+			lastEl = gcpDetailsEl;
+			break;
+		case "serverUserName":
+			$("#serverUserNamePrefix").text(data.UserNamePrefix);
+			$("#serverUserNameSuffix").val(data.UserNameSuffix);
+			$("#serverUserNameDomain").text(data.UserNameDomain);
+			lastEl = serverUserNameEl;
+			break;
+		case "serverSecretseed":
+			$("#serverSecretseedKeyDir").text(data.KeyDir);
+			$("#serverSecretseedSecretSeed").text(data.SecretSeed);
+			lastEl = serverSecretseedEl;
+			break;
+		case "serverHostName":
+			serverHostNameEl.find(".up-ipAddr").text(data.IPAddr);
+			lastEl = serverHostNameEl;
+			break;
+		case "serverWriters":
+			serverWritersEl.find("#serverWriters").val(data.Writers.join("\n"));
+			lastEl = serverWritersEl;
+			break;
 		}
 		lastStep = data.Step;
 
 		// Re-enable buttons, hide old errors, show the dialog.
-		lastEl.find("button").prop("disabled", false);
+		lastEl.find("button, input").prop("disabled", false);
 		lastEl.find(".up-error").hide();
 		lastEl.modal("show");
 	}