diff --git a/Makefile b/Makefile index b65b6f500..042c46f2f 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GOPATH=$(shell go env GOPATH) # gofmt UNFORMATTED_FILES=$(shell find . -not -path "./vendor/*" -name "*.go" | xargs gofmt -s -l) -EXECUTABLE_FILES=$(shell find . -type f -executable | egrep -v '^\./(website/[vendor|tmp]|vendor/|\.git|bin/|scripts/|pkg/)' | egrep -v '.*(\.sh|\.bats|\.git)' | egrep -v './provisioner/ansible/test-fixtures/exit1') +EXECUTABLE_FILES=$(shell find . -type f -executable | egrep -v '^\./(website/[vendor|tmp]|vendor/|\.git|bin/|scripts/|pkg/)' | egrep -v '.*(\.sh|\.bats|\.git)' | egrep -v './provisioner/(ansible|inspec)/test-fixtures/exit1') # Get the git commit GIT_DIRTY=$(shell test -n "`git status --porcelain`" && echo "+CHANGES" || true) diff --git a/command/plugin.go b/command/plugin.go index 825f06683..3bbe8965e 100644 --- a/command/plugin.go +++ b/command/plugin.go @@ -71,6 +71,7 @@ import ( chefsoloprovisioner "github.com/hashicorp/packer/provisioner/chef-solo" convergeprovisioner "github.com/hashicorp/packer/provisioner/converge" fileprovisioner "github.com/hashicorp/packer/provisioner/file" + inspecprovisioner "github.com/hashicorp/packer/provisioner/inspec" powershellprovisioner "github.com/hashicorp/packer/provisioner/powershell" puppetmasterlessprovisioner "github.com/hashicorp/packer/provisioner/puppet-masterless" puppetserverprovisioner "github.com/hashicorp/packer/provisioner/puppet-server" @@ -130,6 +131,7 @@ var Provisioners = map[string]packer.Provisioner{ "chef-solo": new(chefsoloprovisioner.Provisioner), "converge": new(convergeprovisioner.Provisioner), "file": new(fileprovisioner.Provisioner), + "inspec": new(inspecprovisioner.Provisioner), "powershell": new(powershellprovisioner.Provisioner), "puppet-masterless": new(puppetmasterlessprovisioner.Provisioner), "puppet-server": new(puppetserverprovisioner.Provisioner), diff --git a/provisioner/inspec/adapter.go b/provisioner/inspec/adapter.go new file mode 100644 index 000000000..ebd52d0a3 --- /dev/null +++ b/provisioner/inspec/adapter.go @@ -0,0 +1,285 @@ +package inspec + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "net" + + "github.com/hashicorp/packer/packer" + "golang.org/x/crypto/ssh" +) + +// An adapter satisfies SSH requests (from an Inspec client) by delegating SSH +// exec and subsystem commands to a packer.Communicator. +type adapter struct { + done <-chan struct{} + l net.Listener + config *ssh.ServerConfig + ui packer.Ui + comm packer.Communicator +} + +func newAdapter(done <-chan struct{}, l net.Listener, config *ssh.ServerConfig, ui packer.Ui, comm packer.Communicator) *adapter { + return &adapter{ + done: done, + l: l, + config: config, + ui: ui, + comm: comm, + } +} + +func (c *adapter) Serve() { + log.Printf("SSH proxy: serving on %s", c.l.Addr()) + + for { + // Accept will return if either the underlying connection is closed or if a connection is made. + // after returning, check to see if c.done can be received. If so, then Accept() returned because + // the connection has been closed. + conn, err := c.l.Accept() + select { + case <-c.done: + return + default: + if err != nil { + c.ui.Error(fmt.Sprintf("listen.Accept failed: %v", err)) + continue + } + go func(conn net.Conn) { + if err := c.Handle(conn, c.ui); err != nil { + c.ui.Error(err.Error()) + } + }(conn) + } + } +} + +func (c *adapter) Handle(conn net.Conn, ui packer.Ui) error { + log.Print("SSH proxy: accepted connection") + _, chans, reqs, err := ssh.NewServerConn(conn, c.config) + if err != nil { + return errors.New("failed to handshake") + } + + // discard all global requests + go ssh.DiscardRequests(reqs) + + // Service the incoming NewChannels + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + + go func(ch ssh.NewChannel) { + if err := c.handleSession(ch); err != nil { + c.ui.Error(err.Error()) + } + }(newChannel) + } + + return nil +} + +func (c *adapter) handleSession(newChannel ssh.NewChannel) error { + channel, requests, err := newChannel.Accept() + if err != nil { + return err + } + defer channel.Close() + + done := make(chan struct{}) + + // Sessions have requests such as "pty-req", "shell", "env", and "exec". + // see RFC 4254, section 6 + go func(in <-chan *ssh.Request) { + env := make([]envRequestPayload, 4) + for req := range in { + switch req.Type { + case "pty-req": + log.Println("inspec provisioner pty-req request") + // accept pty-req requests, but don't actually do anything. Necessary for OpenSSH and sudo. + req.Reply(true, nil) + + case "env": + req, err := newEnvRequest(req) + if err != nil { + c.ui.Error(err.Error()) + req.Reply(false, nil) + continue + } + env = append(env, req.Payload) + log.Printf("new env request: %s", req.Payload) + req.Reply(true, nil) + case "exec": + req, err := newExecRequest(req) + if err != nil { + c.ui.Error(err.Error()) + req.Reply(false, nil) + close(done) + continue + } + + log.Printf("new exec request: %s", req.Payload) + + if len(req.Payload) == 0 { + req.Reply(false, nil) + close(done) + return + } + + go func(channel ssh.Channel) { + exit := c.exec(string(req.Payload), channel, channel, channel.Stderr()) + + exitStatus := make([]byte, 4) + binary.BigEndian.PutUint32(exitStatus, uint32(exit)) + channel.SendRequest("exit-status", false, exitStatus) + close(done) + }(channel) + req.Reply(true, nil) + case "subsystem": + req, err := newSubsystemRequest(req) + if err != nil { + c.ui.Error(err.Error()) + req.Reply(false, nil) + continue + } + + log.Printf("new subsystem request: %s", req.Payload) + + c.ui.Error(fmt.Sprintf("unsupported subsystem requested: %s", req.Payload)) + req.Reply(false, nil) + default: + log.Printf("rejecting %s request", req.Type) + req.Reply(false, nil) + } + } + }(requests) + + <-done + return nil +} + +func (c *adapter) Shutdown() { + c.l.Close() +} + +func (c *adapter) exec(command string, in io.Reader, out io.Writer, err io.Writer) int { + var exitStatus int + exitStatus = c.remoteExec(command, in, out, err) + return exitStatus +} + +func (c *adapter) remoteExec(command string, in io.Reader, out io.Writer, err io.Writer) int { + cmd := &packer.RemoteCmd{ + Stdin: in, + Stdout: out, + Stderr: err, + Command: command, + } + + if err := c.comm.Start(cmd); err != nil { + c.ui.Error(err.Error()) + return cmd.ExitStatus + } + + cmd.Wait() + + return cmd.ExitStatus +} + +type envRequest struct { + *ssh.Request + Payload envRequestPayload +} + +type envRequestPayload struct { + Name string + Value string +} + +func (p envRequestPayload) String() string { + return fmt.Sprintf("%s=%s", p.Name, p.Value) +} + +func newEnvRequest(raw *ssh.Request) (*envRequest, error) { + r := new(envRequest) + r.Request = raw + + if err := ssh.Unmarshal(raw.Payload, &r.Payload); err != nil { + return nil, err + } + + return r, nil +} + +func sshString(buf io.Reader) (string, error) { + var size uint32 + err := binary.Read(buf, binary.BigEndian, &size) + if err != nil { + return "", err + } + + b := make([]byte, size) + err = binary.Read(buf, binary.BigEndian, b) + if err != nil { + return "", err + } + return string(b), nil +} + +type execRequest struct { + *ssh.Request + Payload execRequestPayload +} + +type execRequestPayload string + +func (p execRequestPayload) String() string { + return string(p) +} + +func newExecRequest(raw *ssh.Request) (*execRequest, error) { + r := new(execRequest) + r.Request = raw + buf := bytes.NewReader(r.Request.Payload) + + var err error + var payload string + if payload, err = sshString(buf); err != nil { + return nil, err + } + + r.Payload = execRequestPayload(payload) + return r, nil +} + +type subsystemRequest struct { + *ssh.Request + Payload subsystemRequestPayload +} + +type subsystemRequestPayload string + +func (p subsystemRequestPayload) String() string { + return string(p) +} + +func newSubsystemRequest(raw *ssh.Request) (*subsystemRequest, error) { + r := new(subsystemRequest) + r.Request = raw + buf := bytes.NewReader(r.Request.Payload) + + var err error + var payload string + if payload, err = sshString(buf); err != nil { + return nil, err + } + + r.Payload = subsystemRequestPayload(payload) + return r, nil +} diff --git a/provisioner/inspec/adapter_test.go b/provisioner/inspec/adapter_test.go new file mode 100644 index 000000000..638cead58 --- /dev/null +++ b/provisioner/inspec/adapter_test.go @@ -0,0 +1,116 @@ +package inspec + +import ( + "errors" + "io" + "log" + "net" + "os" + "testing" + "time" + + "github.com/hashicorp/packer/packer" + + "golang.org/x/crypto/ssh" +) + +func TestAdapter_Serve(t *testing.T) { + + // done signals the adapter that the provisioner is done + done := make(chan struct{}) + + acceptC := make(chan struct{}) + l := listener{done: make(chan struct{}), acceptC: acceptC} + + config := &ssh.ServerConfig{} + + ui := new(packer.NoopUi) + + sut := newAdapter(done, &l, config, newUi(ui), communicator{}) + go func() { + i := 0 + for range acceptC { + i++ + if i == 4 { + close(done) + l.Close() + } + } + }() + + sut.Serve() +} + +type listener struct { + done chan struct{} + acceptC chan<- struct{} + i int +} + +func (l *listener) Accept() (net.Conn, error) { + log.Println("Accept() called") + l.acceptC <- struct{}{} + select { + case <-l.done: + log.Println("done, serving an error") + return nil, errors.New("listener is closed") + + case <-time.After(10 * time.Millisecond): + l.i++ + + if l.i%2 == 0 { + c1, c2 := net.Pipe() + + go func(c net.Conn) { + <-time.After(100 * time.Millisecond) + log.Println("closing c") + c.Close() + }(c1) + + return c2, nil + } + } + + return nil, errors.New("accept error") +} + +func (l *listener) Close() error { + close(l.done) + return nil +} + +func (l *listener) Addr() net.Addr { + return addr{} +} + +type addr struct{} + +func (a addr) Network() string { + return a.String() +} + +func (a addr) String() string { + return "test" +} + +type communicator struct{} + +func (c communicator) Start(*packer.RemoteCmd) error { + return errors.New("communicator not supported") +} + +func (c communicator) Upload(string, io.Reader, *os.FileInfo) error { + return errors.New("communicator not supported") +} + +func (c communicator) UploadDir(dst string, src string, exclude []string) error { + return errors.New("communicator not supported") +} + +func (c communicator) Download(string, io.Writer) error { + return errors.New("communicator not supported") +} + +func (c communicator) DownloadDir(src string, dst string, exclude []string) error { + return errors.New("communicator not supported") +} diff --git a/provisioner/inspec/provisioner.go b/provisioner/inspec/provisioner.go new file mode 100644 index 000000000..b520c1a66 --- /dev/null +++ b/provisioner/inspec/provisioner.go @@ -0,0 +1,563 @@ +package inspec + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "os" + "os/exec" + "os/user" + "regexp" + "strconv" + "strings" + "sync" + "unicode" + + "golang.org/x/crypto/ssh" + + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/helper/config" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template/interpolate" +) + +var SupportedBackends = map[string]bool{"docker": true, "local": true, "ssh": true, "winrm": true} + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + ctx interpolate.Context + + // The command to run inspec + Command string + SubCommand string + + // Extra options to pass to the inspec command + ExtraArguments []string `mapstructure:"extra_arguments"` + InspecEnvVars []string `mapstructure:"inspec_env_vars"` + + // The profile to execute. + Profile string `mapstructure:"profile"` + AttributesDirectory string `mapstructure:"attributes_directory"` + AttributesFiles []string `mapstructure:"attributes"` + Backend string `mapstructure:"backend"` + User string `mapstructure:"user"` + Host string `mapstructure:"host"` + LocalPort string `mapstructure:"local_port"` + SSHHostKeyFile string `mapstructure:"ssh_host_key_file"` + SSHAuthorizedKeyFile string `mapstructure:"ssh_authorized_key_file"` +} + +type Provisioner struct { + config Config + adapter *adapter + done chan struct{} + inspecVersion string + inspecMajVersion uint +} + +func (p *Provisioner) Prepare(raws ...interface{}) error { + p.done = make(chan struct{}) + + err := config.Decode(&p.config, &config.DecodeOpts{ + Interpolate: true, + InterpolateContext: &p.config.ctx, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{}, + }, + }, raws...) + if err != nil { + return err + } + + // Defaults + if p.config.Command == "" { + p.config.Command = "inspec" + } + + if p.config.SubCommand == "" { + p.config.SubCommand = "exec" + } + + var errs *packer.MultiError + err = validateProfileConfig(p.config.Profile) + if err != nil { + errs = packer.MultiErrorAppend(errs, err) + } + + // Check that the authorized key file exists + if len(p.config.SSHAuthorizedKeyFile) > 0 { + err = validateFileConfig(p.config.SSHAuthorizedKeyFile, "ssh_authorized_key_file", true) + if err != nil { + log.Println(p.config.SSHAuthorizedKeyFile, "does not exist") + errs = packer.MultiErrorAppend(errs, err) + } + } + if len(p.config.SSHHostKeyFile) > 0 { + err = validateFileConfig(p.config.SSHHostKeyFile, "ssh_host_key_file", true) + if err != nil { + log.Println(p.config.SSHHostKeyFile, "does not exist") + errs = packer.MultiErrorAppend(errs, err) + } + } + + if _, ok := SupportedBackends[p.config.Backend]; !ok { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("backend: %s must be a valid backend", p.config.Backend)) + } + + if p.config.Backend == "" { + p.config.Backend = "ssh" + } + + if p.config.Host == "" { + p.config.Host = "127.0.0.1" + } + + if len(p.config.LocalPort) > 0 { + if _, err := strconv.ParseUint(p.config.LocalPort, 10, 16); err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("local_port: %s must be a valid port", p.config.LocalPort)) + } + } else { + p.config.LocalPort = "0" + } + + if len(p.config.AttributesDirectory) > 0 { + err = validateDirectoryConfig(p.config.AttributesDirectory, "attrs") + if err != nil { + log.Println(p.config.AttributesDirectory, "does not exist") + errs = packer.MultiErrorAppend(errs, err) + } + } + + if p.config.User == "" { + usr, err := user.Current() + if err != nil { + errs = packer.MultiErrorAppend(errs, err) + } else { + p.config.User = usr.Username + } + } + if p.config.User == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("user: could not determine current user from environment.")) + } + + if errs != nil && len(errs.Errors) > 0 { + return errs + } + return nil +} + +func (p *Provisioner) getVersion() error { + out, err := exec.Command(p.config.Command, "version").Output() + if err != nil { + return fmt.Errorf( + "Error running \"%s version\": %s", p.config.Command, err.Error()) + } + + versionRe := regexp.MustCompile(`\w (\d+\.\d+[.\d+]*)`) + matches := versionRe.FindStringSubmatch(string(out)) + if matches == nil { + return fmt.Errorf( + "Could not find %s version in output:\n%s", p.config.Command, string(out)) + } + + version := matches[1] + log.Printf("%s version: %s", p.config.Command, version) + p.inspecVersion = version + + majVer, err := strconv.ParseUint(strings.Split(version, ".")[0], 10, 0) + if err != nil { + return fmt.Errorf("Could not parse major version from \"%s\".", version) + } + p.inspecMajVersion = uint(majVer) + + return nil +} + +func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { + ui.Say("Provisioning with Inspec...") + + for i, envVar := range p.config.InspecEnvVars { + envVar, err := interpolate.Render(envVar, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate inspec env vars: %s", err) + } + p.config.InspecEnvVars[i] = envVar + } + + for i, arg := range p.config.ExtraArguments { + arg, err := interpolate.Render(arg, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate inspec extra arguments: %s", err) + } + p.config.ExtraArguments[i] = arg + } + + for i, arg := range p.config.AttributesFiles { + arg, err := interpolate.Render(arg, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate inspec attributes: %s", err) + } + p.config.AttributesFiles[i] = arg + } + + k, err := newUserKey(p.config.SSHAuthorizedKeyFile) + if err != nil { + return err + } + + hostSigner, err := newSigner(p.config.SSHHostKeyFile) + // Remove the private key file + if len(k.privKeyFile) > 0 { + defer os.Remove(k.privKeyFile) + } + + keyChecker := ssh.CertChecker{ + UserKeyFallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if user := conn.User(); user != p.config.User { + return nil, errors.New(fmt.Sprintf("authentication failed: %s is not a valid user", user)) + } + + if !bytes.Equal(k.Marshal(), pubKey.Marshal()) { + return nil, errors.New("authentication failed: unauthorized key") + } + + return nil, nil + }, + } + + config := &ssh.ServerConfig{ + AuthLogCallback: func(conn ssh.ConnMetadata, method string, err error) { + log.Printf("authentication attempt from %s to %s as %s using %s", conn.RemoteAddr(), conn.LocalAddr(), conn.User(), method) + }, + PublicKeyCallback: keyChecker.Authenticate, + //NoClientAuth: true, + } + + config.AddHostKey(hostSigner) + + localListener, err := func() (net.Listener, error) { + port, err := strconv.ParseUint(p.config.LocalPort, 10, 16) + if err != nil { + return nil, err + } + + tries := 1 + if port != 0 { + tries = 10 + } + for i := 0; i < tries; i++ { + l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + port++ + if err != nil { + ui.Say(err.Error()) + continue + } + _, p.config.LocalPort, err = net.SplitHostPort(l.Addr().String()) + if err != nil { + ui.Say(err.Error()) + continue + } + return l, nil + } + return nil, errors.New("Error setting up SSH proxy connection") + }() + + if err != nil { + return err + } + + ui = newUi(ui) + p.adapter = newAdapter(p.done, localListener, config, ui, comm) + + defer func() { + log.Print("shutting down the SSH proxy") + close(p.done) + p.adapter.Shutdown() + }() + + go p.adapter.Serve() + + tf, err := ioutil.TempFile(p.config.AttributesDirectory, "packer-provisioner-inspec.*.yml") + if err != nil { + return fmt.Errorf("Error preparing packer attributes file: %s", err) + } + defer os.Remove(tf.Name()) + + w := bufio.NewWriter(tf) + w.WriteString(fmt.Sprintf("packer_build_name: %s\n", p.config.PackerBuildName)) + w.WriteString(fmt.Sprintf("packer_builder_type: %s\n", p.config.PackerBuilderType)) + + if err := w.Flush(); err != nil { + tf.Close() + return fmt.Errorf("Error preparing packer attributes file: %s", err) + } + tf.Close() + p.config.AttributesFiles = append(p.config.AttributesFiles, tf.Name()) + + if err := p.executeInspec(ui, comm, k.privKeyFile); err != nil { + return fmt.Errorf("Error executing Inspec: %s", err) + } + + return nil +} +func (p *Provisioner) Cancel() { + if p.done != nil { + close(p.done) + } + if p.adapter != nil { + p.adapter.Shutdown() + } + os.Exit(0) +} + +func (p *Provisioner) executeInspec(ui packer.Ui, comm packer.Communicator, privKeyFile string) error { + var envvars []string + + args := []string{p.config.SubCommand, p.config.Profile} + args = append(args, "--backend", p.config.Backend) + args = append(args, "--host", p.config.Host) + + if p.config.Backend == "ssh" { + if len(privKeyFile) > 0 { + args = append(args, "--key-files", privKeyFile) + } + args = append(args, "--user", p.config.User) + args = append(args, "--port", p.config.LocalPort) + } + + args = append(args, "--attrs") + args = append(args, p.config.AttributesFiles...) + args = append(args, p.config.ExtraArguments...) + + if len(p.config.InspecEnvVars) > 0 { + envvars = append(envvars, p.config.InspecEnvVars...) + } + + cmd := exec.Command(p.config.Command, args...) + + cmd.Env = os.Environ() + if len(envvars) > 0 { + cmd.Env = append(cmd.Env, envvars...) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + + wg := sync.WaitGroup{} + repeat := func(r io.ReadCloser) { + reader := bufio.NewReader(r) + for { + line, err := reader.ReadString('\n') + if line != "" { + line = strings.TrimRightFunc(line, unicode.IsSpace) + ui.Message(line) + } + if err != nil { + if err == io.EOF { + break + } else { + ui.Error(err.Error()) + break + } + } + } + wg.Done() + } + wg.Add(2) + go repeat(stdout) + go repeat(stderr) + + ui.Say(fmt.Sprintf("Executing Inspec: %s", strings.Join(cmd.Args, " "))) + if err := cmd.Start(); err != nil { + return err + } + wg.Wait() + err = cmd.Wait() + if err != nil { + return fmt.Errorf("Non-zero exit status: %s", err) + } + + return nil +} + +func validateFileConfig(name string, config string, req bool) error { + if req { + if name == "" { + return fmt.Errorf("%s must be specified.", config) + } + } + info, err := os.Stat(name) + if err != nil { + return fmt.Errorf("%s: %s is invalid: %s", config, name, err) + } else if info.IsDir() { + return fmt.Errorf("%s: %s must point to a file", config, name) + } + return nil +} + +func validateProfileConfig(name string) error { + if name == "" { + return fmt.Errorf("profile must be specified.") + } + return nil +} + +func validateDirectoryConfig(name string, config string) error { + info, err := os.Stat(name) + if err != nil { + return fmt.Errorf("%s: %s is invalid: %s", config, name, err) + } else if !info.IsDir() { + return fmt.Errorf("%s: %s must point to a directory", config, name) + } + return nil +} + +type userKey struct { + ssh.PublicKey + privKeyFile string +} + +func newUserKey(pubKeyFile string) (*userKey, error) { + userKey := new(userKey) + if len(pubKeyFile) > 0 { + pubKeyBytes, err := ioutil.ReadFile(pubKeyFile) + if err != nil { + return nil, errors.New("Failed to read public key") + } + userKey.PublicKey, _, _, _, err = ssh.ParseAuthorizedKey(pubKeyBytes) + if err != nil { + return nil, errors.New("Failed to parse authorized key") + } + + return userKey, nil + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, errors.New("Failed to generate key pair") + } + userKey.PublicKey, err = ssh.NewPublicKey(key.Public()) + if err != nil { + return nil, errors.New("Failed to extract public key from generated key pair") + } + + // To support Inspec calling back to us we need to write + // this file down + privateKeyDer := x509.MarshalPKCS1PrivateKey(key) + privateKeyBlock := pem.Block{ + Type: "RSA PRIVATE KEY", + Headers: nil, + Bytes: privateKeyDer, + } + tf, err := ioutil.TempFile("", "packer-provisioner-inspec.*.key") + if err != nil { + return nil, errors.New("failed to create temp file for generated key") + } + _, err = tf.Write(pem.EncodeToMemory(&privateKeyBlock)) + if err != nil { + return nil, errors.New("failed to write private key to temp file") + } + + err = tf.Close() + if err != nil { + return nil, errors.New("failed to close private key temp file") + } + userKey.privKeyFile = tf.Name() + + return userKey, nil +} + +type signer struct { + ssh.Signer +} + +func newSigner(privKeyFile string) (*signer, error) { + signer := new(signer) + + if len(privKeyFile) > 0 { + privateBytes, err := ioutil.ReadFile(privKeyFile) + if err != nil { + return nil, errors.New("Failed to load private host key") + } + + signer.Signer, err = ssh.ParsePrivateKey(privateBytes) + if err != nil { + return nil, errors.New("Failed to parse private host key") + } + + return signer, nil + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, errors.New("Failed to generate server key pair") + } + + signer.Signer, err = ssh.NewSignerFromKey(key) + if err != nil { + return nil, errors.New("Failed to extract private key from generated key pair") + } + + return signer, nil +} + +// Ui provides concurrency-safe access to packer.Ui. +type Ui struct { + sem chan int + ui packer.Ui +} + +func newUi(ui packer.Ui) packer.Ui { + return &Ui{sem: make(chan int, 1), ui: ui} +} + +func (ui *Ui) Ask(s string) (string, error) { + ui.sem <- 1 + ret, err := ui.ui.Ask(s) + <-ui.sem + + return ret, err +} + +func (ui *Ui) Say(s string) { + ui.sem <- 1 + ui.ui.Say(s) + <-ui.sem +} + +func (ui *Ui) Message(s string) { + ui.sem <- 1 + ui.ui.Message(s) + <-ui.sem +} + +func (ui *Ui) Error(s string) { + ui.sem <- 1 + ui.ui.Error(s) + <-ui.sem +} + +func (ui *Ui) Machine(t string, args ...string) { + ui.sem <- 1 + ui.ui.Machine(t, args...) + <-ui.sem +} + +func (ui *Ui) ProgressBar() packer.ProgressBar { + return new(packer.NoopProgressBar) +} diff --git a/provisioner/inspec/provisioner_test.go b/provisioner/inspec/provisioner_test.go new file mode 100644 index 000000000..d38d1f0c6 --- /dev/null +++ b/provisioner/inspec/provisioner_test.go @@ -0,0 +1,293 @@ +package inspec + +import ( + "crypto/rand" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "strings" + "testing" + + "github.com/hashicorp/packer/packer" +) + +// Be sure to remove the InSpec stub file in each test with: +// defer os.Remove(config["command"].(string)) +func testConfig(t *testing.T) map[string]interface{} { + m := make(map[string]interface{}) + wd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + inspec_stub := path.Join(wd, "packer-inspec-stub.sh") + + err = ioutil.WriteFile(inspec_stub, []byte("#!/usr/bin/env bash\necho 2.2.16"), 0777) + if err != nil { + t.Fatalf("err: %s", err) + } + m["command"] = inspec_stub + + return m +} + +func TestProvisioner_Impl(t *testing.T) { + var raw interface{} + raw = &Provisioner{} + if _, ok := raw.(packer.Provisioner); !ok { + t.Fatalf("must be a Provisioner") + } +} + +func TestProvisionerPrepare_Defaults(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + err := p.Prepare(config) + if err == nil { + t.Fatalf("should have error") + } + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + config["ssh_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + config["profile"] = profile_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + err = os.Unsetenv("USER") + if err != nil { + t.Fatalf("err: %s", err) + } + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_ProfileFile(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + config["ssh_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + config["profile"] = profile_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } + + test_dir, err := ioutil.TempDir("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(test_dir) + + config["profile"] = test_dir + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_HostKeyFile(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + filename := make([]byte, 10) + n, err := io.ReadFull(rand.Reader, filename) + if n != len(filename) || err != nil { + t.Fatal("could not create random file name") + } + + config["ssh_host_key_file"] = fmt.Sprintf("%x", filename) + config["ssh_authorized_key_file"] = publickey_file.Name() + config["profile"] = profile_file.Name() + + err = p.Prepare(config) + if err == nil { + t.Fatal("should error if ssh_host_key_file does not exist") + } + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + config["ssh_host_key_file"] = hostkey_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_AuthorizedKeyFiles(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + filename := make([]byte, 10) + n, err := io.ReadFull(rand.Reader, filename) + if n != len(filename) || err != nil { + t.Fatal("could not create random file name") + } + + config["ssh_host_key_file"] = hostkey_file.Name() + config["profile"] = profile_file.Name() + config["ssh_authorized_key_file"] = fmt.Sprintf("%x", filename) + + err = p.Prepare(config) + if err == nil { + t.Errorf("should error if ssh_authorized_key_file does not exist") + } + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + config["ssh_authorized_key_file"] = publickey_file.Name() + err = p.Prepare(config) + if err != nil { + t.Errorf("err: %s", err) + } +} + +func TestProvisionerPrepare_LocalPort(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + config["ssh_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + config["profile"] = profile_file.Name() + + config["local_port"] = "65537" + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + config["local_port"] = "22222" + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestInspecGetVersion(t *testing.T) { + if os.Getenv("PACKER_ACC") == "" { + t.Skip("This test is only run with PACKER_ACC=1 and it requires InSpec to be installed") + } + + var p Provisioner + p.config.Command = "inspec exec" + err := p.getVersion() + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestInspecGetVersionError(t *testing.T) { + var p Provisioner + p.config.Command = "./test-fixtures/exit1" + err := p.getVersion() + if err == nil { + t.Fatal("Should return error") + } + if !strings.Contains(err.Error(), "./test-fixtures/exit1 version") { + t.Fatal("Error message should include command name") + } +} diff --git a/provisioner/inspec/test-fixtures/exit1 b/provisioner/inspec/test-fixtures/exit1 new file mode 100755 index 000000000..2bb8d868b --- /dev/null +++ b/provisioner/inspec/test-fixtures/exit1 @@ -0,0 +1,3 @@ +#!/bin/sh + +exit 1 diff --git a/website/source/community-plugins.html.md b/website/source/community-plugins.html.md index bb0cb2490..537cdc3ca 100644 --- a/website/source/community-plugins.html.md +++ b/website/source/community-plugins.html.md @@ -25,6 +25,7 @@ still distributed with Packer. ## Provisioners - File +- InSpec - PowerShell - Shell - Windows Restart diff --git a/website/source/docs/provisioners/inspec.html.md b/website/source/docs/provisioners/inspec.html.md new file mode 100644 index 000000000..809b33b5e --- /dev/null +++ b/website/source/docs/provisioners/inspec.html.md @@ -0,0 +1,135 @@ +--- +description: | + The inspec Packer provisioner allows inspec profiles to be run to test the + machine. +layout: docs +page_title: 'InSpec - Provisioners' +sidebar_current: 'docs-provisioners-inspec' +--- + +# InSpec Provisioner + +Type: `inspec` + +The `inspec` Packer provisioner runs InSpec profiles. It dynamically creates a +target configured to use SSH, runs an SSH server, executes `inspec exec`, and +marshals InSpec tests through the SSH server to the machine being provisioned +by Packer. + +## Basic Example + +This is a fully functional template that will test an image on DigitalOcean. +Replace the mock `api_token` value with your own. + +``` json +{ + "provisioners": [ + { + "type": "inspec", + "profile": "https://github.com/dev-sec/linux-baseline" + } + ], + + "builders": [ + { + "type": "digitalocean", + "api_token": "", + "image": "ubuntu-14-04-x64", + "region": "sfo1" + } + ] +} +``` + +## Configuration Reference + +Required Parameters: + +- `profile` - The profile to be executed by InSpec. + +Optional Parameters: + +- `inspec_env_vars` (array of strings) - Environment variables to set before + running InSpec. Usage example: + + ``` json + { + "inspec_env_vars": [ "FOO=bar" ] + } + ``` + +- `command` (string) - The command to invoke InSpec. Defaults to `inspec`. + +- `extra_arguments` (array of strings) - Extra arguments to pass to InSpec. + These arguments *will not* be passed through a shell and arguments should + not be quoted. Usage example: + + ``` json + { + "extra_arguments": [ "--sudo", "--reporter", "json" ] + } + ``` + +- `attributes` (array of strings) - Attribute Files used by InSpec which will + be passed to the `--attrs` argument of the `inspec` command when this + provisioner runs InSpec. Specify this if you want a different location. + Note using also `"--attrs"` in `extra_arguments` will override this + setting. + +- `attributes_directory` (string) - The directory in which to place the + temporary generated InSpec Attributes file. By default, this is the + system-specific temporary file location. The fully-qualified name of this + temporary file will be passed to the `--attrs` argument of the `inspec` + command when this provisioner runs InSpec. Specify this if you want a + different location. + +- `backend` (string) - Backend used by InSpec for connection. Defaults to + SSH. + +- `host` (string) - Host used for by InSpec for connection. Defaults to + localhost. + +- `local_port` (string) - The port on which to attempt to listen for SSH + connections. This value is a starting point. The provisioner will attempt to + listen for SSH connections on the first available of ten ports, starting at + `local_port`. A system-chosen port is used when `local_port` is missing or + empty. + +- `ssh_host_key_file` (string) - The SSH key that will be used to run the SSH + server on the host machine to forward commands to the target machine. + InSpec connects to this server and will validate the identity of the server + using the system known\_hosts. The default behavior is to generate and use + a onetime key. + +- `ssh_authorized_key_file` (string) - The SSH public key of the InSpec + `ssh_user`. The default behavior is to generate and use a onetime key. If + this key is generated, the corresponding private key is passed to `inspec` + command with the `-i inspec_ssh_private_key_file` option. + +- `user` (string) - The `--user` to use. Defaults to the user running Packer. + +## Default Extra Variables + +In addition to being able to specify extra arguments using the +`extra_arguments` configuration, the provisioner automatically defines certain +commonly useful InSpec Attributes: + +- `packer_build_name` is set to the name of the build that Packer is running. + This is most useful when Packer is making multiple builds and you want to + distinguish them slightly when using a common profile. + +- `packer_builder_type` is the type of the builder that was used to create + the machine that the script is running on. This is useful if you want to + run only certain parts of the profile on systems built with certain + builders. + +## Debugging + +To debug underlying issues with InSpec, add `"-l"` to `"extra_arguments"` to +enable verbose logging. + +``` json +{ + "extra_arguments": [ "-l", "debug" ] +} +``` diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index 5a9f4436d..9c6496594 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -216,6 +216,9 @@ > File + > + InSpec + > PowerShell