diff --git a/builder/googlecompute/builder.go b/builder/googlecompute/builder.go index 47167a43f..f083066c4 100644 --- a/builder/googlecompute/builder.go +++ b/builder/googlecompute/builder.go @@ -66,6 +66,12 @@ func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (pack &StepInstanceInfo{ Debug: b.config.PackerDebug, }, + &StepStartTunnel{ + IAPConf: &b.config.IAPConfig, + CommConf: &b.config.Comm, + AccountFile: b.config.AccountFile, + ProjectId: b.config.ProjectId, + }, &communicator.StepConnect{ Config: &b.config.Comm, Host: communicator.CommHost(b.config.Comm.Host(), "instance_ip"), diff --git a/builder/googlecompute/config.go b/builder/googlecompute/config.go index 75327c73c..e2e7724c0 100644 --- a/builder/googlecompute/config.go +++ b/builder/googlecompute/config.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "regexp" + "runtime" "time" "github.com/hashicorp/packer/common" @@ -72,6 +73,8 @@ type Config struct { // state of your VM instances. Note: integrity monitoring relies on having // vTPM enabled. [Details](https://cloud.google.com/security/shielded-cloud/shielded-vm) EnableIntegrityMonitoring bool `mapstructure:"enable_integrity_monitoring" required:"false"` + // Whether to use an IAP proxy. + IAPConfig `mapstructure:",squash"` // The unique name of the resulting image. Defaults to // `packer-{{timestamp}}`. ImageName string `mapstructure:"image_name" required:"false"` @@ -320,10 +323,36 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { c.StateTimeout = 5 * time.Minute } + // Set up communicator if es := c.Comm.Prepare(&c.ctx); len(es) > 0 { errs = packer.MultiErrorAppend(errs, es...) } + // set defaults for IAP + if c.IAPConfig.IAPHashBang == "" { + if runtime.GOOS == "windows" { + c.IAPConfig.IAPHashBang = "" + } else { + c.IAPConfig.IAPHashBang = "/bin/sh" + } + } + if c.IAPConfig.IAPExt == "" { + if runtime.GOOS == "windows" { + c.IAPConfig.IAPExt = ".cmd" + } + } + + // Configure IAP: Update SSH config to use localhost proxy instead + if c.IAPConfig.IAP { + if c.Comm.Type == "ssh" { + c.Comm.SSHHost = "localhost" + } else { + err := fmt.Errorf("Error: IAP tunnel currently only implemnted for" + + " SSH communicator") + errs = packer.MultiErrorAppend(errs, err) + } + } + // Process required parameters. if c.ProjectId == "" { errs = packer.MultiErrorAppend( diff --git a/builder/googlecompute/config.hcl2spec.go b/builder/googlecompute/config.hcl2spec.go index 124435d7d..e000224f4 100644 --- a/builder/googlecompute/config.hcl2spec.go +++ b/builder/googlecompute/config.hcl2spec.go @@ -70,6 +70,10 @@ type FlatConfig struct { EnableSecureBoot *bool `mapstructure:"enable_secure_boot" required:"false" cty:"enable_secure_boot"` EnableVtpm *bool `mapstructure:"enable_vtpm" required:"false" cty:"enable_vtpm"` EnableIntegrityMonitoring *bool `mapstructure:"enable_integrity_monitoring" required:"false" cty:"enable_integrity_monitoring"` + IAP *bool `mapstructure:"use_iap" required:"false" cty:"use_iap"` + IAPLocalhostPort *int `mapstructure:"iap_localhost_port" cty:"iap_localhost_port"` + IAPHashBang *string `mapstructure:"iap_hashbang" required:"false" cty:"iap_hashbang"` + IAPExt *string `mapstructure:"iap_ext" required:"false" cty:"iap_ext"` ImageName *string `mapstructure:"image_name" required:"false" cty:"image_name"` ImageDescription *string `mapstructure:"image_description" required:"false" cty:"image_description"` ImageEncryptionKey *FlatCustomerEncryptionKey `mapstructure:"image_encryption_key" required:"false" cty:"image_encryption_key"` @@ -175,6 +179,10 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "enable_secure_boot": &hcldec.AttrSpec{Name: "enable_secure_boot", Type: cty.Bool, Required: false}, "enable_vtpm": &hcldec.AttrSpec{Name: "enable_vtpm", Type: cty.Bool, Required: false}, "enable_integrity_monitoring": &hcldec.AttrSpec{Name: "enable_integrity_monitoring", Type: cty.Bool, Required: false}, + "use_iap": &hcldec.AttrSpec{Name: "use_iap", Type: cty.Bool, Required: false}, + "iap_localhost_port": &hcldec.AttrSpec{Name: "iap_localhost_port", Type: cty.Number, Required: false}, + "iap_hashbang": &hcldec.AttrSpec{Name: "iap_hashbang", Type: cty.String, Required: false}, + "iap_ext": &hcldec.AttrSpec{Name: "iap_ext", Type: cty.String, Required: false}, "image_name": &hcldec.AttrSpec{Name: "image_name", Type: cty.String, Required: false}, "image_description": &hcldec.AttrSpec{Name: "image_description", Type: cty.String, Required: false}, "image_encryption_key": &hcldec.BlockSpec{TypeName: "image_encryption_key", Nested: hcldec.ObjectSpec((*FlatCustomerEncryptionKey)(nil).HCL2Spec())}, diff --git a/builder/googlecompute/config_test.go b/builder/googlecompute/config_test.go index 7753e1d8a..b7c46ea56 100644 --- a/builder/googlecompute/config_test.go +++ b/builder/googlecompute/config_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io/ioutil" "os" + "runtime" "strings" "testing" ) @@ -382,6 +383,67 @@ func TestConfigPrepareStartupScriptFile(t *testing.T) { } } +func TestConfigPrepareIAP(t *testing.T) { + config := map[string]interface{}{ + "project_id": "project", + "source_image": "foo", + "ssh_username": "packer", + "zone": "us-central1-a", + "communicator": "ssh", + "use_iap": true, + } + + var c Config + _, err := c.Prepare(config) + if err != nil { + t.Fatalf("Shouldn't have errors. Err = %s", err) + } + + if runtime.GOOS == "windows" { + if c.IAPExt != ".cmd" { + t.Fatalf("IAP tempfile extension didn't default correctly to .cmd") + } + if c.IAPHashBang != "" { + t.Fatalf("IAP hashbang didn't default correctly to nothing.") + } + } else { + if c.IAPExt != "" { + t.Fatalf("IAP tempfile extension should default to empty on unix mahcines") + } + if c.IAPHashBang != "/bin/sh" { + t.Fatalf("IAP hashbang didn't default correctly to /bin/sh.") + } + } + if c.Comm.SSHHost != "localhost" { + t.Fatalf("Didn't correctly override the ssh host.") + } +} + +func TestConfigPrepareIAP_failures(t *testing.T) { + config := map[string]interface{}{ + "project_id": "project", + "source_image": "foo", + "winrm_username": "packer", + "zone": "us-central1-a", + "communicator": "winrm", + "iap_hashbang": "/bin/bash", + "iap_ext": ".ps1", + "use_iap": true, + } + + var c Config + _, errs := c.Prepare(config) + if errs == nil { + t.Fatalf("Should have errored because we're using winrm.") + } + if c.IAPHashBang != "/bin/bash" { + t.Fatalf("IAP hashbang defaulted even though set.") + } + if c.IAPExt != ".ps1" { + t.Fatalf("IAP tempfile defaulted even though set.") + } +} + func TestConfigDefaults(t *testing.T) { cases := []struct { Read func(c *Config) interface{} diff --git a/builder/googlecompute/step_start_tunnel.go b/builder/googlecompute/step_start_tunnel.go new file mode 100644 index 000000000..434d7fe4e --- /dev/null +++ b/builder/googlecompute/step_start_tunnel.go @@ -0,0 +1,331 @@ +//go:generate struct-markdown +//go:generate mapstructure-to-hcl2 -type IAPConfig + +package googlecompute + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "text/template" + "time" + + "github.com/hashicorp/packer/common/net" + "github.com/hashicorp/packer/common/retry" + "github.com/hashicorp/packer/helper/communicator" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/packer/tmp" +) + +// StepStartTunnel represents a Packer build step that launches an IAP tunnel +type IAPConfig struct { + // Whether to use an IAP proxy. + // Prerequisites and limitations for using IAP: + // - You must manually enable the IAP API in the Google Cloud console. + // - You must have the gcloud sdk installed on the computer running Packer. + // - You must be using a Service Account with a credentials file (using the + // account_file option in the Packer template) + // - This is currently only implemented for the SSH communicator, not the + // WinRM Communicator. + // - You must add the given service account to project level IAP permissions + // in https://console.cloud.google.com/security/iap. To do so, click + // "project" > "SSH and TCP resoures" > "All Tunnel Resources" > + // "Add Member". Then add your service account and choose the role + // "IAP-secured Tunnel User" and add any conditions you may care about. + IAP bool `mapstructure:"use_iap" required:"false"` + // Which port to connect the local end of the IAM localhost proxy to. If + // left blank, Packer will choose a port for you from available ports. + IAPLocalhostPort int `mapstructure:"iap_localhost_port"` + // What "hashbang" to use to invoke script that sets up gcloud. + // Default: "/bin/sh" + IAPHashBang string `mapstructure:"iap_hashbang" required:"false"` + // What file extension to use for script that sets up gcloud. + // Default: ".sh" + IAPExt string `mapstructure:"iap_ext" required:"false"` +} + +type TunnelDriver interface { + StartTunnel(context.Context, string) error + StopTunnel() +} + +func RunTunnelCommand(cmd *exec.Cmd) error { + // set stdout and stderr so we can read what's going on. + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Start() + if err != nil { + err := fmt.Errorf("Error calling gcloud sdk to launch IAP tunnel: %s", + err) + return err + } + + // Give tunnel 30 seconds to either launch, or return an error. + // Unfortunately, the SDK doesn't provide any official acknowledgment that + // the tunnel is launched when it's not being run through a TTY so we + // are just trusting here that 30s is enough to know whether the tunnel + // launch was going to fail. Yep, feels icky to me too. But I spent an + // afternoon trying to figure out how to get the SDK to actually send + // the "Listening on port [n]" line I see when I run it manually, and I + // can't justify spending more time than that on aesthetics. + for i := 0; i < 30; i++ { + time.Sleep(1 * time.Second) + + lineStderr, err := stderr.ReadString('\n') + if err != nil && err != io.EOF { + log.Printf("Err from scanning stderr is %s", err) + return fmt.Errorf("Error reading stderr from tunnel launch: %s", err) + } + if lineStderr != "" { + log.Printf("stderr: %s", lineStderr) + } + + lineStdout, err := stdout.ReadString('\n') + if err != nil && err != io.EOF { + log.Printf("Err from scanning stdout is %s", err) + return fmt.Errorf("Error reading stdout from tunnel launch: %s", err) + } + if lineStdout != "" { + log.Printf("stdout: %s", lineStdout) + } + + if strings.Contains(lineStderr, "ERROR") { + // 4033: Either you don't have permission to access the instance, + // the instance doesn't exist, or the instance is stopped. + // The two sub-errors we may see while the permissions settle are + // "not authorized" and "failed to connect to backend," but after + // about a minute of retries this goes away and we're able to + // connect. + // 4003: "failed to connect to backend". Network blip. + if strings.Contains(lineStderr, "4033") || strings.Contains(lineStderr, "4003") { + return RetryableTunnelError{lineStderr} + } else { + log.Printf("NOT RETRYABLE: %s", lineStderr) + return fmt.Errorf("Non-retryable tunnel error: %s", lineStderr) + } + } + } + + log.Printf("No error detected after tunnel launch; continuing...") + return nil +} + +type RetryableTunnelError struct { + s string +} + +func (e RetryableTunnelError) Error() string { + return "Tunnel start: " + e.s +} + +type StepStartTunnel struct { + IAPConf *IAPConfig + CommConf *communicator.Config + AccountFile string + ProjectId string + + tunnelDriver TunnelDriver +} + +func (s *StepStartTunnel) ConfigureLocalHostPort(ctx context.Context) error { + minPortNumber, maxPortNumber := 8000, 9000 + + if s.IAPConf.IAPLocalhostPort != 0 { + minPortNumber = s.IAPConf.IAPLocalhostPort + maxPortNumber = minPortNumber + log.Printf("Using TCP port for %d IAP proxy", s.IAPConf.IAPLocalhostPort) + } else { + log.Printf("Finding an available TCP port for IAP proxy") + } + + l, err := net.ListenRangeConfig{ + Min: minPortNumber, + Max: maxPortNumber, + Addr: "0.0.0.0", + Network: "tcp", + }.Listen(ctx) + + if err != nil { + err := fmt.Errorf("error finding an available port to initiate a session tunnel: %s", err) + return err + } + + s.IAPConf.IAPLocalhostPort = l.Port + l.Close() + log.Printf("Setting up proxy to listen on localhost at %d", + s.IAPConf.IAPLocalhostPort) + + return nil +} + +func (s *StepStartTunnel) createTempGcloudScript(args []string) (string, error) { + // Generate temp script that contains both gcloud auth and gcloud compute + // iap launch call. + + // Create temp file. + tf, err := tmp.File("gcloud-setup") + if err != nil { + return "", fmt.Errorf("Error preparing gcloud setup script: %s", err) + } + defer tf.Close() + // Write our contents to it + writer := bufio.NewWriter(tf) + + if s.IAPConf.IAPHashBang != "" { + s.IAPConf.IAPHashBang = fmt.Sprintf("#!%s\n", s.IAPConf.IAPHashBang) + log.Printf("[INFO] (google): Prepending inline gcloud setup script with %s", + s.IAPConf.IAPHashBang) + _, err = writer.WriteString(s.IAPConf.IAPHashBang) + if err != nil { + return "", fmt.Errorf("Error preparing inline hashbang: %s", err) + } + + } + + launchTemplate := ` +gcloud auth activate-service-account --key-file='{{.AccountFile}}' +gcloud config set project {{.ProjectID}} +{{.Args}} +` + if runtime.GOOS == "windows" { + launchTemplate = ` +call gcloud auth activate-service-account --key-file "{{.AccountFile}}" +call gcloud config set project {{.ProjectID}} +call {{.Args}} +` + } + // call command + args = append([]string{"gcloud"}, args...) + argString := strings.Join(args, " ") + + var tpl = template.Must(template.New("createTunnel").Parse(launchTemplate)) + var b bytes.Buffer + + opts := map[string]string{ + "AccountFile": s.AccountFile, + "ProjectID": s.ProjectId, + "Args": argString, + } + + err = tpl.Execute(&b, opts) + if err != nil { + fmt.Println(err) + } + + if _, err := writer.WriteString(b.String()); err != nil { + return "", fmt.Errorf("Error preparing gcloud shell script: %s", err) + } + + if err := writer.Flush(); err != nil { + return "", fmt.Errorf("Error preparing shell script: %s", err) + } + // Have to close temp file before renaming it or Windows will complain. + tf.Close() + err = os.Chmod(tf.Name(), 0700) + if err != nil { + log.Printf("[ERROR] (google): error modifying permissions of temp script file: %s", err.Error()) + } + + // figure out what extension the file should have, and rename it. + tempScriptFileName := tf.Name() + if s.IAPConf.IAPExt != "" { + err := os.Rename(tempScriptFileName, fmt.Sprintf("%s%s", tempScriptFileName, s.IAPConf.IAPExt)) + if err != nil { + return "", fmt.Errorf("Error setting the correct temp file extension: %s", err) + } + tempScriptFileName = fmt.Sprintf("%s%s", tempScriptFileName, s.IAPConf.IAPExt) + } + + return tempScriptFileName, nil +} + +// Run executes the Packer build step that creates an IAP tunnel. +func (s *StepStartTunnel) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + if !s.IAPConf.IAP { + log.Printf("Skipping step launch IAP tunnel; \"iap\" is false.") + return multistep.ActionContinue + } + + // shell out to create the tunnel. + ui := state.Get("ui").(packer.Ui) + instanceName := state.Get("instance_name").(string) + c := state.Get("config").(*Config) + + ui.Say("Step Launch IAP Tunnel...") + + err := s.ConfigureLocalHostPort(ctx) + if err != nil { + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + // Generate list of args to use to call gcloud cli. + args := []string{"compute", "start-iap-tunnel", instanceName, + strconv.Itoa(s.CommConf.Port()), + fmt.Sprintf("--local-host-port=localhost:%d", s.IAPConf.IAPLocalhostPort), + "--zone", c.Zone, + } + + // This is the port the IAP tunnel listens on, on localhost. + // TODO make setting LocalHostPort optional + s.CommConf.SSHPort = s.IAPConf.IAPLocalhostPort + + log.Printf("Creating tunnel launch script with args %#v", args) + // Create temp file that contains both gcloud authentication, and gcloud + // proxy setup call. + tempScriptFileName, err := s.createTempGcloudScript(args) + if err != nil { + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + defer os.Remove(tempScriptFileName) + + s.tunnelDriver = NewTunnelDriver() + + err = retry.Config{ + Tries: 11, + ShouldRetry: func(err error) bool { + switch err.(type) { + case RetryableTunnelError: + return true + default: + return false + } + }, + RetryDelay: (&retry.Backoff{InitialBackoff: 200 * time.Millisecond, MaxBackoff: 30 * time.Second, Multiplier: 2}).Linear, + }.Run(ctx, func(ctx context.Context) error { + // tunnel launcher/destroyer has to be different on windows vs. unix. + err := s.tunnelDriver.StartTunnel(ctx, tempScriptFileName) + return err + }) + if err != nil { + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + return multistep.ActionContinue +} + +// Cleanup stops the IAP tunnel and cleans up processes. +func (s *StepStartTunnel) Cleanup(state multistep.StateBag) { + if !s.IAPConf.IAP { + log.Printf("Skipping cleanup of IAP tunnel; \"iap\" is false.") + return + } + if s.tunnelDriver != nil { + s.tunnelDriver.StopTunnel() + } +} diff --git a/builder/googlecompute/step_start_tunnel.hcl2spec.go b/builder/googlecompute/step_start_tunnel.hcl2spec.go new file mode 100644 index 000000000..ac617884a --- /dev/null +++ b/builder/googlecompute/step_start_tunnel.hcl2spec.go @@ -0,0 +1,36 @@ +// Code generated by "mapstructure-to-hcl2 -type IAPConfig"; DO NOT EDIT. +package googlecompute + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatIAPConfig is an auto-generated flat version of IAPConfig. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatIAPConfig struct { + IAP *bool `mapstructure:"use_iap" required:"false" cty:"use_iap"` + IAPLocalhostPort *int `mapstructure:"iap_localhost_port" cty:"iap_localhost_port"` + IAPHashBang *string `mapstructure:"iap_hashbang" required:"false" cty:"iap_hashbang"` + IAPExt *string `mapstructure:"iap_ext" required:"false" cty:"iap_ext"` +} + +// FlatMapstructure returns a new FlatIAPConfig. +// FlatIAPConfig is an auto-generated flat version of IAPConfig. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*IAPConfig) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatIAPConfig) +} + +// HCL2Spec returns the hcl spec of a IAPConfig. +// This spec is used by HCL to read the fields of IAPConfig. +// The decoded values from this spec will then be applied to a FlatIAPConfig. +func (*FlatIAPConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "use_iap": &hcldec.AttrSpec{Name: "use_iap", Type: cty.Bool, Required: false}, + "iap_localhost_port": &hcldec.AttrSpec{Name: "iap_localhost_port", Type: cty.Number, Required: false}, + "iap_hashbang": &hcldec.AttrSpec{Name: "iap_hashbang", Type: cty.String, Required: false}, + "iap_ext": &hcldec.AttrSpec{Name: "iap_ext", Type: cty.String, Required: false}, + } + return s +} diff --git a/builder/googlecompute/step_start_tunnel_test.go b/builder/googlecompute/step_start_tunnel_test.go new file mode 100644 index 000000000..47813df8e --- /dev/null +++ b/builder/googlecompute/step_start_tunnel_test.go @@ -0,0 +1,136 @@ +package googlecompute + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "runtime" + "testing" + + "github.com/hashicorp/packer/helper/communicator" +) + +type MockTunnelDriver struct { + StopTunnelCalled bool + StartTunnelCalled bool +} + +func (m *MockTunnelDriver) StopTunnel() { + m.StopTunnelCalled = true +} + +func (m *MockTunnelDriver) StartTunnel(context.Context, string) error { + m.StartTunnelCalled = true + return nil +} + +func getTestStepStartTunnel() *StepStartTunnel { + return &StepStartTunnel{ + IAPConf: &IAPConfig{ + IAP: true, + IAPLocalhostPort: 0, + IAPHashBang: "/bin/bash", + IAPExt: "", + }, + CommConf: &communicator.Config{ + SSH: communicator.SSH{ + SSHPort: 1234, + }, + }, + AccountFile: "/path/to/account_file.json", + ProjectId: "fake-project-123", + } +} + +func TestStepStartTunnel_CreateTempScript(t *testing.T) { + s := getTestStepStartTunnel() + + args := []string{"compute", "start-iap-tunnel", "fakeinstance-12345", + "1234", "--local-host-port=localhost:8774", "--zone", "us-central-b"} + + scriptPath, err := s.createTempGcloudScript(args) + if err != nil { + t.Fatalf("Shouldn't have error building script file.") + } + defer os.Remove(scriptPath) + + f, err := ioutil.ReadFile(scriptPath) + if err != nil { + t.Fatalf("couldn't read created inventoryfile: %s", err) + } + + expected := `#!/bin/bash + +gcloud auth activate-service-account --key-file='/path/to/account_file.json' +gcloud config set project fake-project-123 +gcloud compute start-iap-tunnel fakeinstance-12345 1234 --local-host-port=localhost:8774 --zone us-central-b +` + if runtime.GOOS == "windows" { + // in real life you'd not be passing a HashBang here, but GIGO. + expected = `#!/bin/bash + +call gcloud auth activate-service-account --key-file "/path/to/account_file.json" +call gcloud config set project fake-project-123 +call gcloud compute start-iap-tunnel fakeinstance-12345 1234 --local-host-port=localhost:8774 --zone us-central-b +` + } + if fmt.Sprintf("%s", f) != expected { + t.Fatalf("script didn't match expected:\n\n expected: \n%s\n; recieved: \n%s\n", expected, f) + } +} + +func TestStepStartTunnel_Cleanup(t *testing.T) { + // Check IAP true + s := getTestStepStartTunnel() + td := &MockTunnelDriver{} + s.tunnelDriver = td + + state := testState(t) + s.Cleanup(state) + + if !td.StopTunnelCalled { + t.Fatalf("Should have called StopTunnel, since IAP is true") + } + + // Check IAP false + s = getTestStepStartTunnel() + td = &MockTunnelDriver{} + s.tunnelDriver = td + + s.IAPConf.IAP = false + + s.Cleanup(state) + + if td.StopTunnelCalled { + t.Fatalf("Should not have called StopTunnel, since IAP is false") + } +} + +func TestStepStartTunnel_ConfigurePort_port_set_by_user(t *testing.T) { + s := getTestStepStartTunnel() + s.IAPConf.IAPLocalhostPort = 8447 + + ctx := context.TODO() + err := s.ConfigureLocalHostPort(ctx) + if err != nil { + t.Fatalf("Shouldn't have error detecting port") + } + if s.IAPConf.IAPLocalhostPort != 8447 { + t.Fatalf("Shouldn't have found new port; one was configured.") + } +} + +func TestStepStartTunnel_ConfigurePort_port_not_set_by_user(t *testing.T) { + s := getTestStepStartTunnel() + s.IAPConf.IAPLocalhostPort = 0 + + ctx := context.TODO() + err := s.ConfigureLocalHostPort(ctx) + if err != nil { + t.Fatalf("Shouldn't have error detecting port") + } + if s.IAPConf.IAPLocalhostPort == 0 { + t.Fatalf("Should have found new port; none was configured.") + } +} diff --git a/builder/googlecompute/tunnel_driver.go b/builder/googlecompute/tunnel_driver.go new file mode 100644 index 000000000..8bd5e0a56 --- /dev/null +++ b/builder/googlecompute/tunnel_driver.go @@ -0,0 +1,51 @@ +// +build !windows + +package googlecompute + +import ( + "context" + "log" + "os/exec" + "syscall" +) + +func NewTunnelDriver() TunnelDriver { + return &TunnelDriverLinux{} +} + +type TunnelDriverLinux struct { + cmd *exec.Cmd +} + +func (t *TunnelDriverLinux) StartTunnel(cancelCtx context.Context, tempScriptFileName string) error { + cmd := exec.CommandContext(cancelCtx, tempScriptFileName) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + err := RunTunnelCommand(cmd) + if err != nil { + return err + } + + // Store successful command on step so we can access it to cancel it + // later. + t.cmd = cmd + return nil +} + +func (t *TunnelDriverLinux) StopTunnel() { + if t.cmd != nil && t.cmd.Process != nil { + log.Printf("Cleaning up the IAP tunnel...") + // Why not just cmd.Process.Kill()? I'm glad you asked. The gcloud + // call spawns a python subprocess that listens on the port, and you + // need to use the process _group_ id to halt this process and its + // daemon child. We create the group ID with the syscall.SysProcAttr + // call inside the retry loop above, and then store that ID on the + // command so we can halt it here. + err := syscall.Kill(-t.cmd.Process.Pid, syscall.SIGINT) + if err != nil { + log.Printf("Issue stopping IAP tunnel: %s", err) + } + } else { + log.Printf("Couldn't find IAP tunnel process to kill. Continuing.") + } +} diff --git a/builder/googlecompute/tunnel_driver_windows.go b/builder/googlecompute/tunnel_driver_windows.go new file mode 100644 index 000000000..110b945a3 --- /dev/null +++ b/builder/googlecompute/tunnel_driver_windows.go @@ -0,0 +1,41 @@ +// +build windows + +package googlecompute + +import ( + "context" + "log" + "os/exec" +) + +func NewTunnelDriver() TunnelDriver { + return &TunnelDriverWindows{} +} + +type TunnelDriverWindows struct { + cmd *exec.Cmd +} + +func (t *TunnelDriverWindows) StartTunnel(cancelCtx context.Context, tempScriptFileName string) error { + args := []string{"/C", "call", tempScriptFileName} + cmd := exec.CommandContext(cancelCtx, "cmd", args...) + err := RunTunnelCommand(cmd) + if err != nil { + return err + } + // Store successful command on step so we can access it to cancel it + // later. + t.cmd = cmd + return nil +} + +func (t *TunnelDriverWindows) StopTunnel() { + if t.cmd != nil && t.cmd.Process != nil { + err := t.cmd.Process.Kill() + if err != nil { + log.Printf("Issue stopping IAP tunnel: %s", err) + } + } else { + log.Printf("Couldn't find IAP tunnel process to kill. Continuing.") + } +} diff --git a/post-processor/googlecompute-export/post-processor.go b/post-processor/googlecompute-export/post-processor.go index 4f21e4fde..2d4a2a995 100644 --- a/post-processor/googlecompute-export/post-processor.go +++ b/post-processor/googlecompute-export/post-processor.go @@ -22,6 +22,7 @@ type Config struct { common.PackerConfig `mapstructure:",squash"` AccountFile string `mapstructure:"account_file"` + IAP bool `mapstructure:"iap"` DiskSizeGb int64 `mapstructure:"disk_size"` DiskType string `mapstructure:"disk_type"` diff --git a/post-processor/googlecompute-export/post-processor.hcl2spec.go b/post-processor/googlecompute-export/post-processor.hcl2spec.go index ed91bcccf..156c852b8 100644 --- a/post-processor/googlecompute-export/post-processor.hcl2spec.go +++ b/post-processor/googlecompute-export/post-processor.hcl2spec.go @@ -17,6 +17,7 @@ type FlatConfig struct { PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables"` PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables"` AccountFile *string `mapstructure:"account_file" cty:"account_file"` + IAP *bool `mapstructure:"iap" cty:"iap"` DiskSizeGb *int64 `mapstructure:"disk_size" cty:"disk_size"` DiskType *string `mapstructure:"disk_type" cty:"disk_type"` MachineType *string `mapstructure:"machine_type" cty:"machine_type"` @@ -48,6 +49,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false}, "packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false}, "account_file": &hcldec.AttrSpec{Name: "account_file", Type: cty.String, Required: false}, + "iap": &hcldec.AttrSpec{Name: "iap", Type: cty.Bool, Required: false}, "disk_size": &hcldec.AttrSpec{Name: "disk_size", Type: cty.Number, Required: false}, "disk_type": &hcldec.AttrSpec{Name: "disk_type", Type: cty.String, Required: false}, "machine_type": &hcldec.AttrSpec{Name: "machine_type", Type: cty.String, Required: false}, diff --git a/post-processor/googlecompute-import/post-processor.go b/post-processor/googlecompute-import/post-processor.go index bca834c98..33f2501b9 100644 --- a/post-processor/googlecompute-import/post-processor.go +++ b/post-processor/googlecompute-import/post-processor.go @@ -28,6 +28,7 @@ type Config struct { AccountFile string `mapstructure:"account_file"` ProjectId string `mapstructure:"project_id"` + IAP bool `mapstructure:"iap"` Bucket string `mapstructure:"bucket"` GCSObjectName string `mapstructure:"gcs_object_name"` diff --git a/post-processor/googlecompute-import/post-processor.hcl2spec.go b/post-processor/googlecompute-import/post-processor.hcl2spec.go index 96e597813..c773769f9 100644 --- a/post-processor/googlecompute-import/post-processor.hcl2spec.go +++ b/post-processor/googlecompute-import/post-processor.hcl2spec.go @@ -18,6 +18,7 @@ type FlatConfig struct { PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables"` AccountFile *string `mapstructure:"account_file" cty:"account_file"` ProjectId *string `mapstructure:"project_id" cty:"project_id"` + IAP *bool `mapstructure:"iap" cty:"iap"` Bucket *string `mapstructure:"bucket" cty:"bucket"` GCSObjectName *string `mapstructure:"gcs_object_name" cty:"gcs_object_name"` ImageDescription *string `mapstructure:"image_description" cty:"image_description"` @@ -50,6 +51,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false}, "account_file": &hcldec.AttrSpec{Name: "account_file", Type: cty.String, Required: false}, "project_id": &hcldec.AttrSpec{Name: "project_id", Type: cty.String, Required: false}, + "iap": &hcldec.AttrSpec{Name: "iap", Type: cty.Bool, Required: false}, "bucket": &hcldec.AttrSpec{Name: "bucket", Type: cty.String, Required: false}, "gcs_object_name": &hcldec.AttrSpec{Name: "gcs_object_name", Type: cty.String, Required: false}, "image_description": &hcldec.AttrSpec{Name: "image_description", Type: cty.String, Required: false}, diff --git a/website/pages/docs/builders/googlecompute.mdx b/website/pages/docs/builders/googlecompute.mdx index 0bb5f8c25..5ed73746b 100644 --- a/website/pages/docs/builders/googlecompute.mdx +++ b/website/pages/docs/builders/googlecompute.mdx @@ -217,6 +217,8 @@ builder. @include 'builder/googlecompute/Config-not-required.mdx' +@include 'builder/googlecompute/IAPConfig-not-required.mdx' + ## Startup Scripts Startup scripts can be a powerful tool for configuring the instance from which diff --git a/website/pages/partials/builder/googlecompute/IAPConfig-not-required.mdx b/website/pages/partials/builder/googlecompute/IAPConfig-not-required.mdx new file mode 100644 index 000000000..28bdeb1d7 --- /dev/null +++ b/website/pages/partials/builder/googlecompute/IAPConfig-not-required.mdx @@ -0,0 +1,25 @@ + + +- `use_iap` (bool) - Whether to use an IAP proxy. + Prerequisites and limitations for using IAP: + - You must manually enable the IAP API in the Google Cloud console. + - You must have the gcloud sdk installed on the computer running Packer. + - You must be using a Service Account with a credentials file (using the + account_file option in the Packer template) + - This is currently only implemented for the SSH communicator, not the + WinRM Communicator. + - You must add the given service account to project level IAP permissions + in https://console.cloud.google.com/security/iap. To do so, click + "project" > "SSH and TCP resoures" > "All Tunnel Resources" > + "Add Member". Then add your service account and choose the role + "IAP-secured Tunnel User" and add any conditions you may care about. + +- `iap_localhost_port` (int) - Which port to connect the local end of the IAM localhost proxy to. If + left blank, Packer will choose a port for you from available ports. + +- `iap_hashbang` (string) - What "hashbang" to use to invoke script that sets up gcloud. + Default: "/bin/sh" + +- `iap_ext` (string) - What file extension to use for script that sets up gcloud. + Default: ".sh" + \ No newline at end of file diff --git a/website/pages/partials/builder/googlecompute/IAPConfig.mdx b/website/pages/partials/builder/googlecompute/IAPConfig.mdx new file mode 100644 index 000000000..b43f7ce04 --- /dev/null +++ b/website/pages/partials/builder/googlecompute/IAPConfig.mdx @@ -0,0 +1,2 @@ + +StepStartTunnel represents a Packer build step that launches an IAP tunnel