packer-cn/provisioner/inspec/provisioner.go

543 lines
14 KiB
Go

//go:generate mapstructure-to-hcl2 -type Config
package inspec
import (
"bufio"
"bytes"
"context"
"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/hcl/v2/hcldec"
"github.com/hashicorp/packer/packer-plugin-sdk/adapter"
"github.com/hashicorp/packer/packer-plugin-sdk/common"
packersdk "github.com/hashicorp/packer/packer-plugin-sdk/packer"
"github.com/hashicorp/packer/packer-plugin-sdk/template/config"
"github.com/hashicorp/packer/packer-plugin-sdk/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 invoke InSpec. Defaults to `inspec`.
Command string `mapstructure-to-hcl2:"command"`
SubCommand string `mapstructure-to-hcl2:",skip"`
// 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 int `mapstructure:"local_port"`
SSHHostKeyFile string `mapstructure:"ssh_host_key_file"`
SSHAuthorizedKeyFile string `mapstructure:"ssh_authorized_key_file"`
}
type Provisioner struct {
config Config
adapter *adapter.Adapter
done chan struct{}
inspecVersion string
inspecMajVersion uint
}
func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() }
func (p *Provisioner) Prepare(raws ...interface{}) error {
p.done = make(chan struct{})
err := config.Decode(&p.config, &config.DecodeOpts{
PluginType: "inspec",
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 *packersdk.MultiError
err = validateProfileConfig(p.config.Profile)
if err != nil {
errs = packersdk.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 = packersdk.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 = packersdk.MultiErrorAppend(errs, err)
}
}
if p.config.Backend == "" {
p.config.Backend = "ssh"
}
if _, ok := SupportedBackends[p.config.Backend]; !ok {
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("backend: %s must be a valid backend", p.config.Backend))
}
if p.config.Backend == "docker" && p.config.Host == "" {
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("backend: host must be specified for docker backend"))
}
if p.config.Host == "" {
p.config.Host = "127.0.0.1"
}
if p.config.LocalPort > 65535 {
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("local_port: %d must be a valid port", p.config.LocalPort))
}
if len(p.config.AttributesDirectory) > 0 {
err = validateDirectoryConfig(p.config.AttributesDirectory, "attrs")
if err != nil {
log.Println(p.config.AttributesDirectory, "does not exist")
errs = packersdk.MultiErrorAppend(errs, err)
}
}
if p.config.User == "" {
usr, err := user.Current()
if err != nil {
errs = packersdk.MultiErrorAppend(errs, err)
} else {
p.config.User = usr.Username
}
}
if p.config.User == "" {
errs = packersdk.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(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}) error {
ui.Say("Provisioning with Inspec...")
p.config.ctx.Data = generatedData
userp, err := interpolate.Render(p.config.User, &p.config.ctx)
if err != nil {
return fmt.Errorf("Could not interpolate inspec user: %s", err)
}
p.config.User = userp
host, err := interpolate.Render(p.config.Host, &p.config.ctx)
if err != nil {
return fmt.Errorf("Could not interpolate inspec user: %s", err)
}
p.config.Host = host
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)
if err != nil {
return fmt.Errorf("error creating host signer: %s", err)
}
// 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
},
IsUserAuthority: func(k ssh.PublicKey) bool { return true },
}
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 := p.config.LocalPort
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
}
_, portStr, err := net.SplitHostPort(l.Addr().String())
if err != nil {
ui.Say(err.Error())
continue
}
p.config.LocalPort, err = strconv.Atoi(portStr)
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 = &packersdk.SafeUi{
Sem: make(chan int, 1),
Ui: ui,
}
p.adapter = 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) executeInspec(ui packersdk.Ui, comm packersdk.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.User != "" {
args = append(args, "--user", p.config.User)
}
if p.config.Backend == "ssh" {
if len(privKeyFile) > 0 {
args = append(args, "--key-files", privKeyFile)
}
args = append(args, "--port", strconv.Itoa(p.config.LocalPort))
}
args = append(args, "--input-file")
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
}