diff --git a/communicator/ssh/connect_test.go b/communicator/ssh/connect_test.go new file mode 100644 index 000000000..3e8ba0e6e --- /dev/null +++ b/communicator/ssh/connect_test.go @@ -0,0 +1,73 @@ +package ssh_test + +import ( + "bytes" + "fmt" + "log" + "os" + "os/user" + "testing" + "time" + + helperssh "github.com/hashicorp/packer/helper/ssh" + "golang.org/x/crypto/ssh" +) + +func getIdentityCertFile() (certSigner ssh.Signer, err error) { + usr, _ := user.Current() + privateKeyFile := usr.HomeDir + "/.ssh/id_ed25519" + certificateFile := usr.HomeDir + "/.ssh/id_ed25519-cert.pub" + + return helperssh.FileSignerWithCert(privateKeyFile, certificateFile) +} + +func TestConnectFunc(t *testing.T) { + { + if os.Getenv("PACKER_ACC") == "" { + t.Skip("This test is only run with PACKER_ACC=1") + } + + const host = "mybastionhost.com:2222" + + certSigner, err := getIdentityCertFile() + if err != nil { + panic(fmt.Errorf("we have an error %v", err)) + } + + publicKeys := ssh.PublicKeys(certSigner) + usr, _ := user.Current() + + config := &ssh.ClientConfig{ + User: usr.Username, + Auth: []ssh.AuthMethod{ + publicKeys, + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 30 * time.Second, + } + + println("Dialing", config.User) + connection, err := ssh.Dial("tcp", host, config) + + if err != nil { + log.Fatal("Failed to dial ", err) + return + } + + session, err := connection.NewSession() + if err != nil { + log.Fatal("Failed to create session: ", err) + return + } + defer session.Close() + + var stdoutBuf bytes.Buffer + session.Stdout = &stdoutBuf + + err = session.Run("ls") + if err != nil { + log.Fatal("Failed to ls") + } + fmt.Println(stdoutBuf.String()) + } +} diff --git a/helper/communicator/config.go b/helper/communicator/config.go index 55fe5efe7..874fe254e 100644 --- a/helper/communicator/config.go +++ b/helper/communicator/config.go @@ -111,6 +111,10 @@ type SSH struct { // The `~` can be used in path and will be expanded to the home directory // of current user. SSHPrivateKeyFile string `mapstructure:"ssh_private_key_file" undocumented:"true"` + // Path to user certificate used to authenticate with SSH. + // The `~` can be used in path and will be expanded to the + // home directory of current user. + SSHCertificateFile string `mapstructure:"ssh_certificate_file"` // If `true`, a PTY will be requested for the SSH connection. This defaults // to `false`. SSHPty bool `mapstructure:"ssh_pty"` @@ -148,6 +152,10 @@ type SSH struct { // bastion host. The `~` can be used in path and will be expanded to the // home directory of current user. SSHBastionPrivateKeyFile string `mapstructure:"ssh_bastion_private_key_file"` + // Path to user certificate used to authenticate with bastion host. + // The `~` can be used in path and will be expanded to the + //home directory of current user. + SSHBastionCertificateFile string `mapstructure:"ssh_bastion_certificate_file"` // `scp` or `sftp` - How to transfer files, Secure copy (default) or SSH // File Transfer Protocol. SSHFileTransferMethod string `mapstructure:"ssh_file_transfer_method"` @@ -316,11 +324,29 @@ func (c *Config) SSHConfigFunc() func(multistep.StateBag) (*ssh.ClientConfig, er privateKeys = append(privateKeys, c.SSHPrivateKey) } + certPath := "" + if c.SSHCertificateFile != "" { + var err error + certPath, err = packer.ExpandUser(c.SSHCertificateFile) + if err != nil { + return nil, err + } + } + for _, key := range privateKeys { + signer, err := ssh.ParsePrivateKey(key) if err != nil { return nil, fmt.Errorf("Error on parsing SSH private key: %s", err) } + + if certPath != "" { + signer, err = helperssh.ReadCertificate(certPath, signer) + if err != nil { + return nil, err + } + } + sshConfig.Auth = append(sshConfig.Auth, ssh.PublicKeys(signer)) } @@ -431,6 +457,11 @@ func (c *Config) prepareSSH(ctx *interpolate.Context) []error { if c.SSHBastionPrivateKeyFile == "" && c.SSHPrivateKeyFile != "" { c.SSHBastionPrivateKeyFile = c.SSHPrivateKeyFile } + + if c.SSHBastionCertificateFile == "" && c.SSHCertificateFile != "" { + c.SSHBastionCertificateFile = c.SSHCertificateFile + } + } if c.SSHProxyHost != "" { @@ -462,9 +493,23 @@ func (c *Config) prepareSSH(ctx *interpolate.Context) []error { } else if _, err := os.Stat(path); err != nil { errs = append(errs, fmt.Errorf( "ssh_private_key_file is invalid: %s", err)) - } else if _, err := helperssh.FileSigner(path); err != nil { - errs = append(errs, fmt.Errorf( - "ssh_private_key_file is invalid: %s", err)) + } else { + if c.SSHCertificateFile != "" { + certPath, err := packer.ExpandUser(c.SSHCertificateFile) + if err != nil { + errs = append(errs, fmt.Errorf("invalid identity certificate: #{err}")) + } + + if _, err := helperssh.FileSignerWithCert(path, certPath); err != nil { + errs = append(errs, fmt.Errorf( + "ssh_private_key_file is invalid: %s", err)) + } + } else { + if _, err := helperssh.FileSigner(path); err != nil { + errs = append(errs, fmt.Errorf( + "ssh_private_key_file is invalid: %s", err)) + } + } } } @@ -480,9 +525,22 @@ func (c *Config) prepareSSH(ctx *interpolate.Context) []error { } else if _, err := os.Stat(path); err != nil { errs = append(errs, fmt.Errorf( "ssh_bastion_private_key_file is invalid: %s", err)) - } else if _, err := helperssh.FileSigner(path); err != nil { - errs = append(errs, fmt.Errorf( - "ssh_bastion_private_key_file is invalid: %s", err)) + } else { + if c.SSHBastionCertificateFile != "" { + certPath, err := packer.ExpandUser(c.SSHBastionCertificateFile) + if err != nil { + errs = append(errs, fmt.Errorf("invalid identity certificate: #{err}")) + } + if _, err := helperssh.FileSignerWithCert(path, certPath); err != nil { + errs = append(errs, fmt.Errorf( + "ssh_bastion_private_key_file is invalid: %s", err)) + } + } else { + if _, err := helperssh.FileSigner(path); err != nil { + errs = append(errs, fmt.Errorf( + "ssh_bastion_private_key_file is invalid: %s", err)) + } + } } } } diff --git a/helper/communicator/config.hcl2spec.go b/helper/communicator/config.hcl2spec.go index a5cc481f3..5986d5033 100644 --- a/helper/communicator/config.hcl2spec.go +++ b/helper/communicator/config.hcl2spec.go @@ -20,6 +20,7 @@ type FlatConfig struct { SSHCiphers []string `mapstructure:"ssh_ciphers" cty:"ssh_ciphers" hcl:"ssh_ciphers"` SSHClearAuthorizedKeys *bool `mapstructure:"ssh_clear_authorized_keys" cty:"ssh_clear_authorized_keys" hcl:"ssh_clear_authorized_keys"` SSHPrivateKeyFile *string `mapstructure:"ssh_private_key_file" undocumented:"true" cty:"ssh_private_key_file" hcl:"ssh_private_key_file"` + SSHCertificateFile *string `mapstructure:"ssh_certificate_file" cty:"ssh_certificate_file" hcl:"ssh_certificate_file"` SSHPty *bool `mapstructure:"ssh_pty" cty:"ssh_pty" hcl:"ssh_pty"` SSHTimeout *string `mapstructure:"ssh_timeout" cty:"ssh_timeout" hcl:"ssh_timeout"` SSHWaitTimeout *string `mapstructure:"ssh_wait_timeout" undocumented:"true" cty:"ssh_wait_timeout" hcl:"ssh_wait_timeout"` @@ -33,6 +34,7 @@ type FlatConfig struct { SSHBastionPassword *string `mapstructure:"ssh_bastion_password" cty:"ssh_bastion_password" hcl:"ssh_bastion_password"` SSHBastionInteractive *bool `mapstructure:"ssh_bastion_interactive" cty:"ssh_bastion_interactive" hcl:"ssh_bastion_interactive"` SSHBastionPrivateKeyFile *string `mapstructure:"ssh_bastion_private_key_file" cty:"ssh_bastion_private_key_file" hcl:"ssh_bastion_private_key_file"` + SSHBastionCertificateFile *string `mapstructure:"ssh_bastion_certificate_file" cty:"ssh_bastion_certificate_file" hcl:"ssh_bastion_certificate_file"` SSHFileTransferMethod *string `mapstructure:"ssh_file_transfer_method" cty:"ssh_file_transfer_method" hcl:"ssh_file_transfer_method"` SSHProxyHost *string `mapstructure:"ssh_proxy_host" cty:"ssh_proxy_host" hcl:"ssh_proxy_host"` SSHProxyPort *int `mapstructure:"ssh_proxy_port" cty:"ssh_proxy_port" hcl:"ssh_proxy_port"` @@ -78,6 +80,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "ssh_ciphers": &hcldec.AttrSpec{Name: "ssh_ciphers", Type: cty.List(cty.String), Required: false}, "ssh_clear_authorized_keys": &hcldec.AttrSpec{Name: "ssh_clear_authorized_keys", Type: cty.Bool, Required: false}, "ssh_private_key_file": &hcldec.AttrSpec{Name: "ssh_private_key_file", Type: cty.String, Required: false}, + "ssh_certificate_file": &hcldec.AttrSpec{Name: "ssh_certificate_file", Type: cty.String, Required: false}, "ssh_pty": &hcldec.AttrSpec{Name: "ssh_pty", Type: cty.Bool, Required: false}, "ssh_timeout": &hcldec.AttrSpec{Name: "ssh_timeout", Type: cty.String, Required: false}, "ssh_wait_timeout": &hcldec.AttrSpec{Name: "ssh_wait_timeout", Type: cty.String, Required: false}, @@ -91,6 +94,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "ssh_bastion_password": &hcldec.AttrSpec{Name: "ssh_bastion_password", Type: cty.String, Required: false}, "ssh_bastion_interactive": &hcldec.AttrSpec{Name: "ssh_bastion_interactive", Type: cty.Bool, Required: false}, "ssh_bastion_private_key_file": &hcldec.AttrSpec{Name: "ssh_bastion_private_key_file", Type: cty.String, Required: false}, + "ssh_bastion_certificate_file": &hcldec.AttrSpec{Name: "ssh_bastion_certificate_file", Type: cty.String, Required: false}, "ssh_file_transfer_method": &hcldec.AttrSpec{Name: "ssh_file_transfer_method", Type: cty.String, Required: false}, "ssh_proxy_host": &hcldec.AttrSpec{Name: "ssh_proxy_host", Type: cty.String, Required: false}, "ssh_proxy_port": &hcldec.AttrSpec{Name: "ssh_proxy_port", Type: cty.Number, Required: false}, @@ -127,6 +131,7 @@ type FlatSSH struct { SSHCiphers []string `mapstructure:"ssh_ciphers" cty:"ssh_ciphers" hcl:"ssh_ciphers"` SSHClearAuthorizedKeys *bool `mapstructure:"ssh_clear_authorized_keys" cty:"ssh_clear_authorized_keys" hcl:"ssh_clear_authorized_keys"` SSHPrivateKeyFile *string `mapstructure:"ssh_private_key_file" undocumented:"true" cty:"ssh_private_key_file" hcl:"ssh_private_key_file"` + SSHCertificateFile *string `mapstructure:"ssh_certificate_file" cty:"ssh_certificate_file" hcl:"ssh_certificate_file"` SSHPty *bool `mapstructure:"ssh_pty" cty:"ssh_pty" hcl:"ssh_pty"` SSHTimeout *string `mapstructure:"ssh_timeout" cty:"ssh_timeout" hcl:"ssh_timeout"` SSHWaitTimeout *string `mapstructure:"ssh_wait_timeout" undocumented:"true" cty:"ssh_wait_timeout" hcl:"ssh_wait_timeout"` @@ -140,6 +145,7 @@ type FlatSSH struct { SSHBastionPassword *string `mapstructure:"ssh_bastion_password" cty:"ssh_bastion_password" hcl:"ssh_bastion_password"` SSHBastionInteractive *bool `mapstructure:"ssh_bastion_interactive" cty:"ssh_bastion_interactive" hcl:"ssh_bastion_interactive"` SSHBastionPrivateKeyFile *string `mapstructure:"ssh_bastion_private_key_file" cty:"ssh_bastion_private_key_file" hcl:"ssh_bastion_private_key_file"` + SSHBastionCertificateFile *string `mapstructure:"ssh_bastion_certificate_file" cty:"ssh_bastion_certificate_file" hcl:"ssh_bastion_certificate_file"` SSHFileTransferMethod *string `mapstructure:"ssh_file_transfer_method" cty:"ssh_file_transfer_method" hcl:"ssh_file_transfer_method"` SSHProxyHost *string `mapstructure:"ssh_proxy_host" cty:"ssh_proxy_host" hcl:"ssh_proxy_host"` SSHProxyPort *int `mapstructure:"ssh_proxy_port" cty:"ssh_proxy_port" hcl:"ssh_proxy_port"` @@ -174,6 +180,7 @@ func (*FlatSSH) HCL2Spec() map[string]hcldec.Spec { "ssh_ciphers": &hcldec.AttrSpec{Name: "ssh_ciphers", Type: cty.List(cty.String), Required: false}, "ssh_clear_authorized_keys": &hcldec.AttrSpec{Name: "ssh_clear_authorized_keys", Type: cty.Bool, Required: false}, "ssh_private_key_file": &hcldec.AttrSpec{Name: "ssh_private_key_file", Type: cty.String, Required: false}, + "ssh_certificate_file": &hcldec.AttrSpec{Name: "ssh_certificate_file", Type: cty.String, Required: false}, "ssh_pty": &hcldec.AttrSpec{Name: "ssh_pty", Type: cty.Bool, Required: false}, "ssh_timeout": &hcldec.AttrSpec{Name: "ssh_timeout", Type: cty.String, Required: false}, "ssh_wait_timeout": &hcldec.AttrSpec{Name: "ssh_wait_timeout", Type: cty.String, Required: false}, @@ -187,6 +194,7 @@ func (*FlatSSH) HCL2Spec() map[string]hcldec.Spec { "ssh_bastion_password": &hcldec.AttrSpec{Name: "ssh_bastion_password", Type: cty.String, Required: false}, "ssh_bastion_interactive": &hcldec.AttrSpec{Name: "ssh_bastion_interactive", Type: cty.Bool, Required: false}, "ssh_bastion_private_key_file": &hcldec.AttrSpec{Name: "ssh_bastion_private_key_file", Type: cty.String, Required: false}, + "ssh_bastion_certificate_file": &hcldec.AttrSpec{Name: "ssh_bastion_certificate_file", Type: cty.String, Required: false}, "ssh_file_transfer_method": &hcldec.AttrSpec{Name: "ssh_file_transfer_method", Type: cty.String, Required: false}, "ssh_proxy_host": &hcldec.AttrSpec{Name: "ssh_proxy_host", Type: cty.String, Required: false}, "ssh_proxy_port": &hcldec.AttrSpec{Name: "ssh_proxy_port", Type: cty.Number, Required: false}, diff --git a/helper/communicator/config_test.go b/helper/communicator/config_test.go index 4f717270b..074687d92 100644 --- a/helper/communicator/config_test.go +++ b/helper/communicator/config_test.go @@ -139,6 +139,30 @@ func TestConfig_winrm_use_ntlm(t *testing.T) { } +func TestSSHBastion(t *testing.T) { + c := &Config{ + Type: "ssh", + SSH: SSH{ + SSHUsername: "root", + SSHBastionHost: "mybastionhost.company.com", + SSHBastionPassword: "test", + }, + } + + if err := c.Prepare(testContext(t)); len(err) > 0 { + t.Fatalf("bad: %#v", err) + } + + if c.SSHBastionCertificateFile != "" { + t.Fatalf("Identity certificate somehow set") + } + + if c.SSHPrivateKeyFile != "" { + t.Fatalf("Private key file somehow set") + } + +} + func TestSSHConfigFunc(t *testing.T) { state := new(multistep.BasicStateBag) @@ -170,7 +194,9 @@ func TestSSHConfigFunc(t *testing.T) { if sshConfig.Config.Ciphers[0] != "partycipher" { t.Fatalf("ssh_ciphers should be a direct passthrough.") } - + if c.SSHCertificateFile != "" { + t.Fatalf("Identity certificate somehow set") + } } func TestConfig_winrm(t *testing.T) { diff --git a/helper/communicator/step_connect_ssh.go b/helper/communicator/step_connect_ssh.go index 8ad82c23e..3ddaed115 100644 --- a/helper/communicator/step_connect_ssh.go +++ b/helper/communicator/step_connect_ssh.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "golang.org/x/crypto/ssh/terminal" "io" "log" "net" @@ -12,6 +11,8 @@ import ( "strings" "time" + "golang.org/x/crypto/ssh/terminal" + "github.com/hashicorp/packer/communicator/ssh" "github.com/hashicorp/packer/helper/multistep" helperssh "github.com/hashicorp/packer/helper/ssh" @@ -277,12 +278,23 @@ func sshBastionConfig(config *Config) (*gossh.ClientConfig, error) { "Error expanding path for SSH bastion private key: %s", err) } - signer, err := helperssh.FileSigner(path) - if err != nil { - return nil, err + if config.SSHBastionCertificateFile != "" { + identityPath, err := packer.ExpandUser(config.SSHBastionCertificateFile) + if err != nil { + return nil, fmt.Errorf("Error expanding path for SSH bastion identity certificate: %s", err) + } + signer, err := helperssh.FileSignerWithCert(path, identityPath) + if err != nil { + return nil, err + } + auth = append(auth, gossh.PublicKeys(signer)) + } else { + signer, err := helperssh.FileSigner(path) + if err != nil { + return nil, err + } + auth = append(auth, gossh.PublicKeys(signer)) } - - auth = append(auth, gossh.PublicKeys(signer)) } if config.SSHBastionAgentAuth { diff --git a/helper/ssh/ssh.go b/helper/ssh/ssh.go index 0de73b78c..b8635483a 100644 --- a/helper/ssh/ssh.go +++ b/helper/ssh/ssh.go @@ -5,12 +5,12 @@ import ( "fmt" "io/ioutil" "os" + "time" "golang.org/x/crypto/ssh" ) -// FileSigner returns an ssh.Signer for a key file. -func FileSigner(path string) (ssh.Signer, error) { +func parseKeyFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, err @@ -34,6 +34,16 @@ func FileSigner(path string) (ssh.Signer, error) { "Failed to read key '%s': password protected keys are\n"+ "not supported. Please decrypt the key prior to use.", path) } + return keyBytes, nil +} + +// FileSigner returns an ssh.Signer for a key file. +func FileSigner(path string) (ssh.Signer, error) { + + keyBytes, err := parseKeyFile(path) + if err != nil { + return nil, fmt.Errorf("Error setting up SSH config: %s", err) + } signer, err := ssh.ParsePrivateKey(keyBytes) if err != nil { @@ -42,3 +52,64 @@ func FileSigner(path string) (ssh.Signer, error) { return signer, nil } + +func ReadCertificate(certificatePath string, keySigner ssh.Signer) (ssh.Signer, error) { + + if certificatePath == "" { + return keySigner, fmt.Errorf("no certificate file provided") + } + + // Load the certificate + cert, err := ioutil.ReadFile(certificatePath) + if err != nil { + return nil, fmt.Errorf("unable to read certificate file: %v", err) + } + + pk, _, _, _, err := ssh.ParseAuthorizedKey(cert) + if err != nil { + return nil, fmt.Errorf("unable to parse public key: %v", err) + } + + certificate, ok := pk.(*ssh.Certificate) + + if !ok { + return nil, fmt.Errorf("Error loading certificate") + } + + err = checkValidCert(certificate) + + if err != nil { + return nil, fmt.Errorf("%s not a valid cert: %v", certificatePath, err) + } + + certSigner, err := ssh.NewCertSigner(certificate, keySigner) + if err != nil { + return nil, fmt.Errorf("failed to create cert signer: %v", err) + } + + return certSigner, nil +} + +// FileSigner returns an ssh.Signer for a key file. +func FileSignerWithCert(path string, certificatePath string) (ssh.Signer, error) { + + keySigner, err := FileSigner(path) + + if err != nil { + return nil, err + } + return ReadCertificate(certificatePath, keySigner) +} + +func checkValidCert(cert *ssh.Certificate) error { + const CertTimeInfinity = 1<<64 - 1 + unixNow := time.Now().Unix() + + if after := int64(cert.ValidAfter); after < 0 || unixNow < int64(cert.ValidAfter) { + return fmt.Errorf("ssh: cert is not yet valid") + } + if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(CertTimeInfinity) && (unixNow >= before || before < 0) { + return fmt.Errorf("ssh: cert has expired") + } + return nil +}