Skip to content

Commit

Permalink
Merge pull request hashicorp#16972 from hashicorp/jbardin/ssh-agent-i…
Browse files Browse the repository at this point in the history
…dentity

ssh connection `agent_identity`
  • Loading branch information
jbardin authored Jan 5, 2018
2 parents 504ea57 + 75f2f61 commit bf5944a
Show file tree
Hide file tree
Showing 25 changed files with 1,253 additions and 356 deletions.
132 changes: 130 additions & 2 deletions communicator/ssh/provisioner.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package ssh

import (
"bytes"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"strings"
"time"

"github.com/hashicorp/terraform/communicator/shared"
Expand Down Expand Up @@ -50,6 +54,8 @@ type connectionInfo struct {
BastionPrivateKey string `mapstructure:"bastion_private_key"`
BastionHost string `mapstructure:"bastion_host"`
BastionPort int `mapstructure:"bastion_port"`

AgentIdentity string `mapstructure:"agent_identity"`
}

// parseConnectionInfo is used to convert the ConnInfo of the InstanceState into
Expand Down Expand Up @@ -186,7 +192,8 @@ type sshClientConfigOpts struct {

func buildSSHClientConfig(opts sshClientConfigOpts) (*ssh.ClientConfig, error) {
conf := &ssh.ClientConfig{
User: opts.user,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: opts.user,
}

if opts.privateKey != "" {
Expand Down Expand Up @@ -246,6 +253,7 @@ func connectToAgent(connInfo *connectionInfo) (*sshAgent, error) {
return &sshAgent{
agent: agent,
conn: conn,
id: connInfo.AgentIdentity,
}, nil

}
Expand All @@ -255,6 +263,7 @@ func connectToAgent(connInfo *connectionInfo) (*sshAgent, error) {
type sshAgent struct {
agent agent.Agent
conn net.Conn
id string
}

func (a *sshAgent) Close() error {
Expand All @@ -265,8 +274,127 @@ func (a *sshAgent) Close() error {
return a.conn.Close()
}

// make an attempt to either read the identity file or find a corresponding
// public key file using the typical openssh naming convention.
// This returns the public key in wire format, or nil when a key is not found.
func findIDPublicKey(id string) []byte {
for _, d := range idKeyData(id) {
signer, err := ssh.ParsePrivateKey(d)
if err == nil {
log.Println("[DEBUG] parsed id private key")
pk := signer.PublicKey()
return pk.Marshal()
}

// try it as a publicKey
pk, err := ssh.ParsePublicKey(d)
if err == nil {
log.Println("[DEBUG] parsed id public key")
return pk.Marshal()
}

// finally try it as an authorized key
pk, _, _, _, err = ssh.ParseAuthorizedKey(d)
if err == nil {
log.Println("[DEBUG] parsed id authorized key")
return pk.Marshal()
}
}

return nil
}

// Try to read an id file using the id as the file path. Also read the .pub
// file if it exists, as the id file may be encrypted. Return only the file
// data read. We don't need to know what data came from which path, as we will
// try parsing each as a private key, a public key and an authorized key
// regardless.
func idKeyData(id string) [][]byte {
idPath, err := filepath.Abs(id)
if err != nil {
return nil
}

var fileData [][]byte

paths := []string{idPath}

if !strings.HasSuffix(idPath, ".pub") {
paths = append(paths, idPath+".pub")
}

for _, p := range paths {
d, err := ioutil.ReadFile(p)
if err != nil {
log.Printf("[DEBUG] error reading %q: %s", p, err)
continue
}
log.Printf("[DEBUG] found identity data at %q", p)
fileData = append(fileData, d)
}

return fileData
}

// sortSigners moves a signer with an agent comment field matching the
// agent_identity to the head of the list when attempting authentication. This
// helps when there are more keys loaded in an agent than the host will allow
// attempts.
func (s *sshAgent) sortSigners(signers []ssh.Signer) {
if s.id == "" || len(signers) < 2 {
return
}

// if we can locate the public key, either by extracting it from the id or
// locating the .pub file, then we can more easily determine an exact match
idPk := findIDPublicKey(s.id)

// if we have a signer with a connect field that matches the id, send that
// first, otherwise put close matches at the front of the list.
head := 0
for i := range signers {
pk := signers[i].PublicKey()
k, ok := pk.(*agent.Key)
if !ok {
continue
}

// check for an exact match first
if bytes.Equal(pk.Marshal(), idPk) || s.id == k.Comment {
signers[0], signers[i] = signers[i], signers[0]
break
}

// no exact match yet, move it to the front if it's close. The agent
// may have loaded as a full filepath, while the config refers to it by
// filename only.
if strings.HasSuffix(k.Comment, s.id) {
signers[head], signers[i] = signers[i], signers[head]
head++
continue
}
}

ss := []string{}
for _, signer := range signers {
pk := signer.PublicKey()
k := pk.(*agent.Key)
ss = append(ss, k.Comment)
}
}

func (s *sshAgent) Signers() ([]ssh.Signer, error) {
signers, err := s.agent.Signers()
if err != nil {
return nil, err
}

s.sortSigners(signers)
return signers, nil
}

func (a *sshAgent) Auth() ssh.AuthMethod {
return ssh.PublicKeysCallback(a.agent.Signers)
return ssh.PublicKeysCallback(a.Signers)
}

func (a *sshAgent) ForwardToAgent(client *ssh.Client) error {
Expand Down
105 changes: 105 additions & 0 deletions communicator/ssh/ssh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package ssh

import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"io/ioutil"
"os"
"path/filepath"
"testing"

"golang.org/x/crypto/ssh"
)

// verify that we can locate public key data
func TestFindKeyData(t *testing.T) {
// setup a test directory
td, err := ioutil.TempDir("", "ssh")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(td)
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(td); err != nil {
t.Fatal(err)
}
defer os.Chdir(cwd)

id := "provisioner_id"

pub := generateSSHKey(t, id)
pubData := pub.Marshal()

// backup the pub file, and replace it with a broken file to ensure we
// extract the public key from the private key.
if err := os.Rename(id+".pub", "saved.pub"); err != nil {
t.Fatal(err)
}
if err := ioutil.WriteFile(id+".pub", []byte("not a public key"), 0600); err != nil {
t.Fatal(err)
}

foundData := findIDPublicKey(id)
if !bytes.Equal(foundData, pubData) {
t.Fatalf("public key %q does not match", foundData)
}

// move the pub file back, and break the private key file to simulate an
// encrypted private key
if err := os.Rename("saved.pub", id+".pub"); err != nil {
t.Fatal(err)
}

if err := ioutil.WriteFile(id, []byte("encrypted private key"), 0600); err != nil {
t.Fatal(err)
}

foundData = findIDPublicKey(id)
if !bytes.Equal(foundData, pubData) {
t.Fatalf("public key %q does not match", foundData)
}

// check the file by path too
foundData = findIDPublicKey(filepath.Join(".", id))
if !bytes.Equal(foundData, pubData) {
t.Fatalf("public key %q does not match", foundData)
}
}

func generateSSHKey(t *testing.T, idFile string) ssh.PublicKey {
t.Helper()

priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}

privFile, err := os.OpenFile(idFile, os.O_RDWR|os.O_CREATE, 0600)
defer privFile.Close()
if err != nil {
t.Fatal(err)
}
privPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}
if err := pem.Encode(privFile, privPEM); err != nil {
t.Fatal(err)
}

// generate and write public key
pub, err := ssh.NewPublicKey(&priv.PublicKey)
if err != nil {
t.Fatal(err)
}

err = ioutil.WriteFile(idFile+".pub", ssh.MarshalAuthorizedKey(pub), 0600)
if err != nil {
t.Fatal(err)
}

return pub
}
1 change: 1 addition & 0 deletions terraform/eval_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func (n *EvalValidateProvisioner) validateConnConfig(connConfig *ResourceConfig)
BastionUser interface{} `mapstructure:"bastion_user"`
BastionPassword interface{} `mapstructure:"bastion_password"`
BastionPrivateKey interface{} `mapstructure:"bastion_private_key"`
AgentIdentity interface{} `mapstructure:"agent_identity"`

// For type=winrm only (enforced in winrm communicator)
HTTPS interface{} `mapstructure:"https"`
Expand Down
Loading

0 comments on commit bf5944a

Please sign in to comment.