Files
threshold-plugin-aws/plugin/plugin.go
T
2021-09-17 16:56:01 -07:00

766 lines
21 KiB
Go

package plugin
import (
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/endpoints"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/iam"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/hashicorp/go-secure-stdlib/awsutil"
"google.golang.org/protobuf/types/known/structpb"
)
// AwsPlugin implements the HostPluginServiceServer interface for the
// AWS plugin.
type AwsPlugin struct {
pb.UnimplementedHostPluginServiceServer
}
// Ensure that we are implementing HostPluginServiceServer
var (
_ pb.HostPluginServiceServer = (*AwsPlugin)(nil)
)
func (p *AwsPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, errors.New("catalog is nil")
}
secrets := catalog.GetSecrets()
if secrets == nil {
return nil, errors.New("secrets are required")
}
attrs := catalog.GetAttributes()
if secrets == nil {
return nil, errors.New("attributes are required")
}
if _, err := getValidateRegionValue(attrs); err != nil {
return nil, err
}
accessKeyId, err := getStringValue(secrets, constAccessKeyId, true)
if err != nil {
return nil, err
}
secretAccessKey, err := getStringValue(secrets, constSecretAccessKey, true)
if err != nil {
return nil, err
}
skipRotate, err := getBoolValue(attrs, constDisableCredentialRotation, false)
if err != nil {
return nil, err
}
// Set up the creds in our persisted state.
state := &awsCatalogPersistedState{
AccessKeyId: accessKeyId,
SecretAccessKey: secretAccessKey,
}
// Try to rotate the credentials if we're not skipping them.
if !skipRotate {
if err := state.RotateCreds(); err != nil {
return nil, fmt.Errorf("error during credential rotation: %w", err)
}
}
persistedProto, err := state.ToProto()
if err != nil {
return nil, err
}
return &pb.OnCreateCatalogResponse{
Persisted: persistedProto,
}, nil
}
func (p *AwsPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
// For host catalogs we only need to really validate what is
// currently in the new copy of the host catalog data, so skip
// fetching the new copy to keep things a little less messy.
catalog := req.GetNewCatalog()
if catalog == nil {
return nil, errors.New("new catalog is nil")
}
var updateSecrets bool
secrets := catalog.GetSecrets()
if secrets == nil {
// We will be updating secrets this run, but what exactly that
// means will be determined later.
updateSecrets = true
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, errors.New("new catalog missing attributes")
}
// Validate the region.
if _, err := getValidateRegionValue(attrs); err != nil {
return nil, err
}
// Get the persisted data.
// NOTE: We might need to change this at a later time. I'm not too
// sure *exactly* what scenarios we might encounter that would
// ultimately mean that we would have to handle an empty or missing
// state in update, but what we are ultimately assuming here
// (implicitly through awsCatalogPersistedStateFromProto) is that
// the state will exist and be populated. Personally I think this
// is fine and important, but this may change in the future.
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted())
if err != nil {
return nil, err
}
skipRotate, err := getBoolValue(attrs, constDisableCredentialRotation, false)
if err != nil {
return nil, err
}
if skipRotate && !updateSecrets {
// This is a validate check to make sure that we aren't disabling
// rotation for credentials currently being managed by rotation.
// This is not allowed.
if !state.CredsLastRotatedTime.IsZero() {
return nil, errors.New("cannot disable rotation for already-rotated credentials")
}
}
if updateSecrets {
accessKeyId, err := getStringValue(secrets, constAccessKeyId, true)
if err != nil {
return nil, err
}
secretAccessKey, err := getStringValue(secrets, constSecretAccessKey, true)
if err != nil {
return nil, err
}
// Replace the credentials. This checks the timestamp on the last
// rotation time as well and deletes the credentials if we are
// managing them (ie: if we've rotated them before).
if err := state.ReplaceCreds(accessKeyId, secretAccessKey); err != nil {
return nil, fmt.Errorf("error attempting to replace credentials: %w", err)
}
}
if !skipRotate && state.CredsLastRotatedTime.IsZero() {
// If we're enabling rotation now but didn't before, or have
// freshly replaced credentials, we can rotate here.
if err := state.RotateCreds(); err != nil {
return nil, fmt.Errorf("error during credential rotation: %w", err)
}
}
// That's it!
persistedProto, err := state.ToProto()
if err != nil {
return nil, err
}
return &pb.OnUpdateCatalogResponse{
Persisted: persistedProto,
}, nil
}
func (p *AwsPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
// Get the persisted data.
// NOTE: We return on error here, blocking the delete. This may or
// may not be an overzealous approach to maintaining database/state
// integrity. May need to be changed at later time if there are
// scenarios where we might be deleting things and any secret state
// may be corrupt/and or legitimately missing.
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted())
if err != nil {
return nil, err
}
if !state.CredsLastRotatedTime.IsZero() {
// Delete old/existing credentials. This is done with the same
// credentials to ensure that it has the proper permissions to do
// it.
if err := state.DeleteCreds(); err != nil {
return nil, err
}
}
return nil, nil
}
func (p *AwsPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, errors.New("catalog is nil")
}
catalogAttrs := catalog.GetAttributes()
if catalogAttrs == nil {
return nil, errors.New("catalog missing attributes")
}
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted())
if err != nil {
return nil, err
}
// Get/validate the region.
region, err := getValidateRegionValue(catalogAttrs)
if err != nil {
return nil, err
}
set := req.GetSet()
if set == nil {
return nil, errors.New("set is nil")
}
setAttrs := set.GetAttributes()
if setAttrs == nil {
return nil, errors.New("set missing attributes")
}
ec2Client, err := state.EC2Client(region)
if err != nil {
return nil, fmt.Errorf("error getting EC2 client: %w", err)
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, fmt.Errorf("error building DescribeInstances parameters: %w", err)
}
if _, err := ec2Client.DescribeInstances(input); err != nil {
return nil, fmt.Errorf("error performing dry run of DescribeInstances: %w", err)
}
return &pb.OnCreateSetResponse{}, nil
}
func (p *AwsPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, errors.New("catalog is nil")
}
catalogAttrs := catalog.GetAttributes()
if catalogAttrs == nil {
return nil, errors.New("catalog missing attributes")
}
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted())
if err != nil {
return nil, err
}
// Get/validate the region.
region, err := getValidateRegionValue(catalogAttrs)
if err != nil {
return nil, err
}
// As with catalog, we don't need to really look at the old host
// set here, just need to work off of/validate the new set config
set := req.GetNewSet()
if set == nil {
return nil, errors.New("new set is nil")
}
setAttrs := set.GetAttributes()
if setAttrs == nil {
return nil, errors.New("new set missing attributes")
}
ec2Client, err := state.EC2Client(region)
if err != nil {
return nil, fmt.Errorf("error getting EC2 client: %w", err)
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, fmt.Errorf("error building DescribeInstances parameters: %w", err)
}
if _, err := ec2Client.DescribeInstances(input); err != nil {
return nil, fmt.Errorf("error performing dry run of DescribeInstances: %w", err)
}
return &pb.OnUpdateSetResponse{}, nil
}
func (p *AwsPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
// No-op, AWS host set does not maintain anything that requires
// cleanup
return nil, nil
}
func (p *AwsPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, errors.New("catalog is nil")
}
catalogAttrs := catalog.GetAttributes()
if catalogAttrs == nil {
return nil, errors.New("catalog missing attributes")
}
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted())
if err != nil {
return nil, err
}
// Get/validate the region.
region, err := getValidateRegionValue(catalogAttrs)
if err != nil {
return nil, err
}
sets := req.GetSets()
if set == nil {
return nil, errors.New("set is nil")
}
// Build all the queries in advance.
type hostSetQuery struct {
Id string
Input *ec2.DescribeInstancesInput
Output *ec2.DescribeInstancesOutput
}
queries := make([]hostSetQuery, len(sets))
for i, set := range sets {
setAttrs := set.GetAttributes()
if setAttrs == nil {
return nil, errors.New("set missing attributes")
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, fmt.Errorf("error building DescribeInstances parameters for host set id %q: %w", set.GetId(), err)
}
queries[i] = hostSetQuery{
Id: set.GetId(),
Input: input,
}
}
ec2Client, err := state.EC2Client(region)
if err != nil {
return nil, fmt.Errorf("error getting EC2 client: %w", err)
}
// Run all queries now and assemble output.
for i, query range queries {
output, err := ec2Client.DescribeInstances(query.Input)
if err != nil {
return nil, fmt.Errorf("error running DescribeInstances for host set id %q: %w", query.Id, err)
}
queries[i].Output = output
}
// Now process the results. Basically go through each set of output
// and assemble
}
type awsCatalogPersistedState struct {
AccessKeyId string
SecretAccessKey string
CredsLastRotatedTime time.Time
}
func awsCatalogPersistedStateFromProto(in *pb.HostCatalogPersisted) (*awsCatalogPersistedState, error) {
data := in.GetData()
if data != nil {
return nil, errors.New("missing data")
}
accessKeyId, err := getStringValue(data, constAccessKeyId, true)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
secretAccessKey, err := getStringValue(data, constSecretAccessKey, true)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
credsLastRotatedTime, err := getTimeValue(data, constDisableCredentialRotation)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
return &awsCatalogPersistedState{
AccessKeyId: accessKeyId,
SecretAccessKey: secretAccessKey,
CredsLastRotatedTime: credsLastRotatedTime,
}, nil
}
func (s *awsCatalogPersistedState) ToProto() (*pb.HostCatalogPersisted, error) {
data, err := structpb.NewStruct(map[string]interface{}{
constAccessKeyId: s.AccessKeyId,
constSecretAccessKey: s.SecretAccessKey,
constCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
})
if err != nil {
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
}
return &pb.HostCatalogPersisted{Data: data}, nil
}
func (s *awsCatalogPersistedState) RotateCreds() error {
c, err := awsutil.NewCredentialsConfig(
awsutil.WithAccessKey(s.AccessKeyId),
awsutil.WithSecretKey(s.SecretAccessKey),
)
if err != nil {
return fmt.Errorf("error loading credentials: %w", err)
}
if err := c.RotateKeys(); err != nil {
return fmt.Errorf("error rotating credentials: %w", err)
}
s.AccessKeyId = c.AccessKey
s.SecretAccessKey = c.SecretKey
s.CredsLastRotatedTime = time.Now()
return nil
}
// ReplaceCreds replaces the access key in the state with a new key.
// If the existing key was rotated at any point in time, it is
// deleted first, otherwise it's left alone.
func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error {
if accessKeyId == "" {
return errors.New("access key id cannot be empty")
}
if secretAccessKey == "" {
return errors.New("secret access key cannot be empty")
}
if accessKeyId == s.AccessKeyId {
return errors.New("attempting to replace access key with the same one")
}
if !s.CredsLastRotatedTime.IsZero() {
// Delete old/existing credentials. This is done with the same
// credentials to ensure that it has the proper permissions to do
// it.
if err := s.DeleteCreds(); err != nil {
return err
}
}
// Set the new attributes and clear the rotated time.
s.AccessKeyId = accessKeyId
s.SecretAccessKey = secretAccessKey
s.CredsLastRotatedTime = time.Time{}
return nil
}
// DeleteCreds deletes the credentials in the state. The access key
// ID, secret access key, and rotation time fields are zeroed out in
// the state just to ensure that they cannot be re-used after.
func (s *awsCatalogPersistedState) DeleteCreds() error {
c, err := awsutil.NewCredentialsConfig(
awsutil.WithAccessKey(s.AccessKeyId),
awsutil.WithSecretKey(s.SecretAccessKey),
)
if err != nil {
return fmt.Errorf("error loading credentials: %w", err)
}
if err := c.DeleteAccessKey(s.AccessKeyId); err != nil {
// Determine if the deletion error was due to a missing
// resource. If it was, just pass it.
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeNoSuchEntityException {
s.AccessKeyId = ""
s.SecretAccessKey = ""
s.CredsLastRotatedTime = time.Time{}
return nil
}
// Otherwise treat it as an actual error.
return fmt.Errorf("error deleting old access key: %w", err)
}
s.AccessKeyId = ""
s.SecretAccessKey = ""
s.CredsLastRotatedTime = time.Time{}
return nil
}
// GetSession returns a configured AWS session for the credentials in
// the state.
func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) {
c, err := awsutil.NewCredentialsConfig(
awsutil.WithAccessKey(s.AccessKeyId),
awsutil.WithSecretKey(s.SecretAccessKey),
)
if err != nil {
return nil, err
}
return c.GetSession()
}
// EC2Client returns a configured EC2 client based on the session
// information stored in the state.
func (s *awsCatalogPersistedState) EC2Client(region string) (*ec2.EC2, error) {
sess, err := s.GetSession()
if err != nil {
return nil, fmt.Errorf("error getting AWS session: %w", err)
}
return ec2.New(sess, aws.NewConfig().WithRegion(region)), nil
}
func getStringValue(in *structpb.Struct, k string, required bool) (string, error) {
mv := in.AsMap()
v, ok := mv[k]
if !ok {
if required {
return "", fmt.Errorf("missing required value %q", k)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v)
}
if s == "" && required {
return "", fmt.Errorf("value %q cannot be empty", k)
}
return s, nil
}
func getBoolValue(in *structpb.Struct, k string, required bool) (bool, error) {
mv := in.AsMap()
v, ok := mv[k]
if !ok {
if required {
return false, fmt.Errorf("missing required value %q", k)
}
return false, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v)
}
return b, nil
}
func getTimeValue(in *structpb.Struct, k string) (time.Time, error) {
mv := in.AsMap()
v, ok := mv[k]
if !ok {
return time.Time{}, nil
}
tRaw, ok := v.(string)
if !ok {
return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v)
}
t, err := time.Parse(time.RFC3339Nano, tRaw)
if err != nil {
return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err)
}
return t, nil
}
func getMapValue(in *structpb.Struct, k string) (map[string]interface{}, error) {
mv := in.AsMap()
v, ok := mv[k]
if !ok {
return make(map[string]interface{}), nil
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected type for value %q: want map, got %T", k, v)
}
return m, nil
}
func getValidateRegionValue(in *structpb.Struct) (string, error) {
region, err := getStringValue(in, constRegion, true)
if err != nil {
return "", err
}
_, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region)
if !found {
return "", fmt.Errorf("not a valid region: %s", region)
}
return region, nil
}
func buildFilters(in *structpb.Struct) ([]*ec2.Filter, error) {
var filters []*ec2.Filter
m, err := getMapValue(in, constDescribeInstancesFilters)
if err != nil {
return nil, err
}
for k, v := range m {
w, ok := v.([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected type for filter values in %q: want array, got %T", k, v)
}
var filterValues []*string
for _, e := range w {
f, ok := e.(string)
if !ok {
return nil, fmt.Errorf("unexpected type for filter value %v: want array, got %T", e, e)
}
filterValues = append(filterValues, aws.String(f))
}
filters = append(filters, &ec2.Filter{
Name: aws.String(k),
Values: filterValues,
})
}
return filters, nil
}
func buildDescribeInstancesInput(attrs *structpb.Struct, dryRun bool) (*ec2.DescribeInstancesInput, error) {
filters, err := buildFilters(setAttrs)
if err != nil {
return nil, fmt.Errorf("error building filters: %w", err)
}
return &ec2.DescribeInstancesInput{
DryRun: aws.Bool(dryRun),
Filters: filters,
})
}
// awsInstanceToHost processes data from an ec2.Instance and returns
// a ListHostsResponseHost with the ID and network addresses
// populated.
//
// Returns nil if the instance is not ready (ie: powered down,
// hibernated, or terminated).
func awsInstanceToHost(instance *ec2.Instance) (*pb.ListHostsResponseHost, error) {
// Integrity check: some fields should always be non-nil. Check
// those.
if instance == nil {
return nil, errors.New("response integrity error: missing instance entry")
}
if instance.InstanceState == nil {
return nil, errors.New("response integrity error: missing instance state")
}
if aws.StringValue(instance.InstanceState.Name) == "" {
return nil, errors.New("response integrity error: missing instance state name")
}
if aws.StringValue(instance.InstanceId) == "" {
return nil, errors.New("response integrity error: missing instance id")
}
// Abort this if the instance is not ready
if aws.StringValue(instance.InstanceState.Name) != ec2.InstanceStateNameRunning {
return nil
}
result := new(pb.ListHostsResponseHost)
// External ID is the instance ID.
result.ExternalId = aws.StringValue(instance.InstanceId)
// First IP address is always the private IP address field if it's
// populated
if aws.StringValue(instance.PrivateIpAddress) != "" {
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PrivateIpAddress))
}
// Public IP address is next.
if aws.StringValue(instance.PublicIpAddress) != "" {
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PublicIpAddress))
}
// Now go through all of the interfaces and log the IP address of
// every interface.
for _, iface := range instance.NetworkInterfaces {
if iface == nil {
// Probably will never happen, but just in case
return nil, errors.New("response integrity error: interface entry is nil")
}
// Iterate through the private IP addresses and log the
// information.
for _, addr := range iface.PrivateIpAddresses {
if addr == nil {
return nil, errors.New("response integrity error: interface address entry is nil")
}
// Check to see if the PrivateIpAddress matches the one
// reported at the top-level of the instance (let's call it the
// "default address"). If it doesn't, add it.
if aws.StringValue(addr.PrivateIpAddress) != "" {
if aws.StringValue(instance.PrivateIpAddress) != aws.StringValue(addr.PrivateIpAddress) {
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress))
}
}
// Do the same for the default public IP address and the
// public association on the interface.
if addr.Association != nil && addr.Assoication.PubilcIp != nil && *addr.Assoication.PubilcIp != "" {
if instance.PublicIpAddress != nil && *instance.PublicIpAddress != *addr.Assoication.PubilcIp {
result.IpAddresses = append(result.IpAddresses, *addr.Assoication.PubilcIp)
}
}
}
// Add the IPv6 addresses.
for _, addr := range iface.Ipv6Addresses {
if addr == nil {
continue
}
if addr.Ipv6Address != nil && *addr.Ipv6Address != "" {
result.IpAddresses = append(result.IpAddresses, *addr.Ipv6Address)
}
}
}
// Done
return result
}