517 lines
16 KiB
Go
517 lines
16 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/profiles/latest/network/mgmt/network"
|
|
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/resources"
|
|
"github.com/kr/pretty"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
|
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
|
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
|
)
|
|
|
|
// AzurePlugin implements the HostPluginServiceServer interface
|
|
type AzurePlugin struct {
|
|
pb.UnimplementedHostPluginServiceServer
|
|
}
|
|
|
|
// Ensure that we are implementing HostPluginServiceServer
|
|
var (
|
|
_ pb.HostPluginServiceServer = (*AzurePlugin)(nil)
|
|
)
|
|
|
|
func rotateCredFromCallback(ctx context.Context, catalog *hostcatalogs.HostCatalog) (*pb.HostCatalogPersisted, error) {
|
|
authConfig, err := getWrappedHamiltonConfig(catalog, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting hauth config when creating catalog: %w", err)
|
|
}
|
|
newPass, err := rotateCredential(ctx, authConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error rotating credentials when creating catalog: %w", err)
|
|
}
|
|
if newPass == nil {
|
|
return nil, errors.New("new credential back from rotate is nil")
|
|
}
|
|
newSecrets, err := structpb.NewStruct(map[string]interface{}{
|
|
constSecretId: *newPass.KeyId,
|
|
constSecretValue: *newPass.SecretText,
|
|
constCredsLastRotatedTime: time.Now().Format(time.RFC3339Nano),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error formatting new credential data as struct")
|
|
}
|
|
return &pb.HostCatalogPersisted{
|
|
Secrets: newSecrets,
|
|
}, err
|
|
}
|
|
|
|
func (p *AzurePlugin) 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 {
|
|
// Secrets will have to be provided later, on update
|
|
return nil, nil
|
|
}
|
|
|
|
sanitizeSecretsFields(secrets)
|
|
|
|
// Check if rotation is skipped. Always prefer attempting rotation unless we
|
|
// explicitly are told not to
|
|
var skipRotation bool
|
|
if attrs := catalog.GetAttributes(); attrs != nil {
|
|
if fields := attrs.GetFields(); fields != nil {
|
|
if field := fields[constDisableCredentialRotation]; field != nil {
|
|
if field.GetBoolValue() {
|
|
skipRotation = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if skipRotation {
|
|
return &pb.OnCreateCatalogResponse{
|
|
Persisted: &pb.HostCatalogPersisted{
|
|
Secrets: secrets,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
newPersisted, err := rotateCredFromCallback(ctx, catalog)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error formatting new persisted data: %w", err)
|
|
}
|
|
ret := &pb.OnCreateCatalogResponse{
|
|
Persisted: newPersisted,
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func (p *AzurePlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
|
newCatalog := req.GetNewCatalog()
|
|
if newCatalog == nil {
|
|
// Should never happen
|
|
return nil, errors.New("new catalog is nil")
|
|
}
|
|
currentCatalog := req.GetCurrentCatalog()
|
|
if currentCatalog == nil {
|
|
// Should never happen
|
|
return nil, errors.New("current catalog is nil")
|
|
}
|
|
secrets := newCatalog.GetSecrets()
|
|
if secrets == nil {
|
|
// If new secrets weren't passed in, don't rotate what we have on
|
|
// update.
|
|
//
|
|
// TODO: There may be combinations of attributes where those values
|
|
// changing (or being added) should trigger rotation.
|
|
return nil, nil
|
|
}
|
|
sanitizeSecretsFields(secrets)
|
|
|
|
// If the old credential had been rotated, attempt to delete the old
|
|
// credential using itself; if it fails, proceed anyways, since in theory
|
|
// only Boundary knows this value so simply forgetting it is safe enough (if
|
|
// not clean).
|
|
var field *structpb.Value
|
|
var pdFields map[string]*structpb.Value
|
|
var err error
|
|
var authConfig *WrappedHamiltonConfig
|
|
|
|
persisted := req.GetPersisted()
|
|
if persisted == nil {
|
|
goto after_revocation
|
|
}
|
|
if persisted.GetSecrets() == nil {
|
|
goto after_revocation
|
|
}
|
|
pdFields = persisted.GetSecrets().GetFields()
|
|
if pdFields == nil {
|
|
goto after_revocation
|
|
}
|
|
field = pdFields[constCredsLastRotatedTime]
|
|
if field == nil {
|
|
goto after_revocation
|
|
}
|
|
// If the field exists, it is a timestamp here indicating last rotation time
|
|
authConfig, err = getWrappedHamiltonConfig(currentCatalog, req.GetPersisted())
|
|
if err != nil {
|
|
// TODO: warn in some way?
|
|
goto after_revocation
|
|
}
|
|
if err := removeCredential(ctx, authConfig); err != nil {
|
|
// TODO: warn
|
|
goto after_revocation
|
|
}
|
|
|
|
after_revocation:
|
|
// Check if rotation is skipped. Always prefer attempting rotation unless we
|
|
// explicitly are told not to
|
|
var skipRotation bool
|
|
if attrs := newCatalog.GetAttributes(); attrs != nil {
|
|
if fields := attrs.GetFields(); fields != nil {
|
|
if field := fields[constDisableCredentialRotation]; field != nil {
|
|
if field.GetBoolValue() {
|
|
skipRotation = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if skipRotation {
|
|
return &pb.OnUpdateCatalogResponse{
|
|
Persisted: &pb.HostCatalogPersisted{
|
|
Secrets: secrets,
|
|
},
|
|
}, nil
|
|
}
|
|
newPersisted, err := rotateCredFromCallback(ctx, req.GetNewCatalog())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error formatting new persisted data: %w", err)
|
|
}
|
|
ret := &pb.OnUpdateCatalogResponse{
|
|
Persisted: newPersisted,
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func (p *AzurePlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
|
if req.GetCatalog() == nil {
|
|
return nil, errors.New("catalog is nil")
|
|
}
|
|
if req.GetPersisted() == nil || req.GetPersisted().GetSecrets() == nil {
|
|
return nil, nil
|
|
}
|
|
// Figure out if creds were rotated
|
|
pdFields := req.GetPersisted().GetSecrets().GetFields()
|
|
if pdFields == nil {
|
|
// No data
|
|
return nil, nil
|
|
}
|
|
field := pdFields[constCredsLastRotatedTime]
|
|
if field == nil {
|
|
return nil, nil
|
|
}
|
|
// The field existing means it is a string containing the last time it was
|
|
// rotated. Delete the credential using itself.
|
|
authConfig, err := getWrappedHamiltonConfig(req.GetCatalog(), req.GetPersisted())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting auth config on catalog deletion: %w", err)
|
|
}
|
|
if err := removeCredential(ctx, authConfig); err != nil {
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error revoking credential on catalog deletion: %w, authConfig is %s", err, pretty.Sprint(authConfig))
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *AzurePlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
|
|
// Nothing yet
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *AzurePlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
|
|
// Nothing yet
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *AzurePlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
|
// Nothing yet
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *AzurePlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
|
|
if len(req.GetSets()) == 0 {
|
|
// Nothing to fetch
|
|
return nil, nil
|
|
}
|
|
catalog := req.GetCatalog()
|
|
if catalog == nil {
|
|
return nil, errors.New("catalog is nil")
|
|
}
|
|
catalogAttrs := catalog.GetAttributes()
|
|
if catalogAttrs == nil {
|
|
return nil, errors.New("catalog has no attributes")
|
|
}
|
|
|
|
// Create an authorizer
|
|
var secrets *structpb.Struct
|
|
if req.GetPersisted() != nil {
|
|
secrets = req.GetPersisted().GetSecrets()
|
|
}
|
|
subscriptionId, authorizer, err := fetchAuthorizer(catalog, secrets)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching subscription id and authorizer: %w", err)
|
|
}
|
|
if subscriptionId == "" {
|
|
return nil, errors.New("subscription id not discovered")
|
|
}
|
|
if authorizer == nil {
|
|
return nil, errors.New("fetched authorizer is nil")
|
|
}
|
|
|
|
commonOpts := make([]Option, 0, 3)
|
|
commonOpts = append(commonOpts,
|
|
WithSubscriptionId(subscriptionId),
|
|
WithAuthorizer(authorizer),
|
|
)
|
|
|
|
// Getting authorizer will fail if the attr fields are not populated, so
|
|
// it's safe to access directly at this point.
|
|
//
|
|
// TODO: plumb base URL everywhere, not just here
|
|
catalogAttrFields := catalogAttrs.GetFields()
|
|
if catalogAttrFields[constBaseUrl] != nil {
|
|
if baseUrl := catalogAttrFields[constBaseUrl].GetStringValue(); baseUrl != "" {
|
|
commonOpts = append(commonOpts, WithBaseUrl(baseUrl))
|
|
}
|
|
}
|
|
|
|
// Create clients
|
|
resClient, err := getResourcesClient(commonOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching resources client: %w", err)
|
|
}
|
|
if resClient == nil {
|
|
return nil, errors.New("resource client is nil")
|
|
}
|
|
vmClient, err := getVirtualMachinesClient(commonOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching virtual machines client: %w", err)
|
|
}
|
|
if vmClient == nil {
|
|
return nil, errors.New("virtual machines client is nil")
|
|
}
|
|
ifClient, err := getNetworkInterfacesClient(commonOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching network interfaces client: %w", err)
|
|
}
|
|
if ifClient == nil {
|
|
return nil, errors.New("network interfaces client is nil")
|
|
}
|
|
pipClient, err := getPublicIpAddressesClient(commonOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching public ip address client: %w", err)
|
|
}
|
|
if pipClient == nil {
|
|
return nil, errors.New("public ip address client is nil")
|
|
}
|
|
|
|
// This next section finds resource IDs that match to the filter in each
|
|
// host set, and stores a mapping of, for each resource ID, which host sets
|
|
// it belongs to
|
|
resourceToSetMap := make(map[string][]string, len(req.GetSets())*10)
|
|
var resourceInfos []resources.GenericResourceExpanded
|
|
for _, set := range req.GetSets() {
|
|
if set == nil {
|
|
return nil, errors.New("set is nil")
|
|
}
|
|
setAttrs := set.GetAttributes()
|
|
if setAttrs == nil {
|
|
return nil, fmt.Errorf("set %s has no attributes", set.GetId())
|
|
}
|
|
setAttrFields := setAttrs.GetFields()
|
|
if len(setAttrFields) == 0 {
|
|
return nil, fmt.Errorf("set %s attributes has no filter", set.GetId())
|
|
}
|
|
|
|
filter := constDefaultFilter
|
|
if setAttrFields[constFilter] != nil {
|
|
if filter = setAttrFields[constFilter].GetStringValue(); filter == "" {
|
|
return nil, fmt.Errorf("set %s filter is empty", set.GetId())
|
|
}
|
|
}
|
|
|
|
// List values matching the filter; ask for provisioning state information
|
|
// to ensure we are seeing only fully provisioned machines
|
|
{
|
|
iter, err := resClient.ListComplete(ctx, filter, constProvisioningState, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error listing resources: %w", err)
|
|
}
|
|
for iter.NotDone() {
|
|
val := iter.Value()
|
|
if err := iter.NextWithContext(ctx); err != nil {
|
|
return nil, fmt.Errorf("error iterating resources: %w", err)
|
|
}
|
|
if val.ID == nil { // something went wrong, likely iterator has advanced beyond the end
|
|
continue
|
|
}
|
|
if val.ProvisioningState == nil {
|
|
continue
|
|
}
|
|
switch *val.ProvisioningState {
|
|
case string(resources.ProvisioningStateSucceeded):
|
|
default:
|
|
continue
|
|
}
|
|
resourceInfos = append(resourceInfos, val)
|
|
resourceToSetMap[*val.ID] = append(resourceToSetMap[*val.ID], set.GetId())
|
|
}
|
|
}
|
|
}
|
|
|
|
// At this point we have a list of distinct resources and a mapping between
|
|
// them and the set(s) they belong to. Iterate through them and fetch
|
|
// information.
|
|
//
|
|
// Listing returns IDs which are basically URLs that have key-value
|
|
// pairs but not the individual components. Get requires the individual
|
|
// components, and won't accept the ID...
|
|
//
|
|
// Anyways, now we find VM details for those resources
|
|
vmToIfaceMap := make(map[string][]network.Interface)
|
|
{
|
|
for _, res := range resourceInfos {
|
|
resourceGroup, name, err := splitId(*res.ID, constMsComputeService, constVirtualMachinesResource)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error splitting vm id %q: %w", *res.ID, err)
|
|
}
|
|
vm, err := vmClient.Get(ctx, resourceGroup, name, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching vm with id %q: %w", *res.ID, err)
|
|
}
|
|
|
|
iv, err := vmClient.InstanceView(ctx, resourceGroup, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching instance view for vm with id %q: %w", *res.ID, err)
|
|
}
|
|
if iv.Statuses == nil {
|
|
return nil, fmt.Errorf("instance view statuses returned for vm with id %q is null", *res.ID)
|
|
}
|
|
var running bool
|
|
for _, status := range *iv.Statuses {
|
|
if status.Code == nil {
|
|
continue
|
|
}
|
|
state := strings.ToLower(*status.Code)
|
|
prefix := "powerstate/"
|
|
if !strings.HasPrefix(state, prefix) {
|
|
continue
|
|
}
|
|
state = strings.TrimPrefix(state, prefix)
|
|
if state == "running" {
|
|
running = true
|
|
break
|
|
}
|
|
}
|
|
if !running {
|
|
continue
|
|
}
|
|
|
|
props := vm.VirtualMachineProperties
|
|
if props == nil {
|
|
return nil, fmt.Errorf("error fetching properties for vm with id %q: %w", *res.ID, err)
|
|
}
|
|
if props.NetworkProfile == nil {
|
|
return nil, fmt.Errorf("error fetching network profile for vm with id %q", *res.ID)
|
|
}
|
|
|
|
// Within the VM, catalog the various interfaces
|
|
var ifaces []network.Interface
|
|
for _, ifaceRef := range *props.NetworkProfile.NetworkInterfaces {
|
|
if ifaceRef.ID == nil {
|
|
return nil, fmt.Errorf("nil ID for network interface for vm with id %q", *res.ID)
|
|
}
|
|
ifResGroup, ifName, err := splitId(*ifaceRef.ID, constMsNetworkService, constNetworkInterfacesResource)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error splitting network interface id %q: %w", *ifaceRef.ID, err)
|
|
}
|
|
iface, err := ifClient.Get(ctx, ifResGroup, ifName, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching network interface with id %q: %w", *ifaceRef.ID, err)
|
|
}
|
|
if iface.InterfacePropertiesFormat == nil || iface.InterfacePropertiesFormat.IPConfigurations == nil {
|
|
continue
|
|
}
|
|
ifaces = append(ifaces, iface)
|
|
}
|
|
vmToIfaceMap[*res.ID] = ifaces
|
|
}
|
|
}
|
|
|
|
// Now, fetch IP details for each of the interfaces for each VM
|
|
type networkInfo struct {
|
|
IpAddresses []string
|
|
}
|
|
vmToNetworkMap := make(map[string]networkInfo)
|
|
{
|
|
for vmId, ifaces := range vmToIfaceMap {
|
|
var netInfo networkInfo
|
|
for _, iface := range ifaces {
|
|
for _, ipconf := range *iface.InterfacePropertiesFormat.IPConfigurations {
|
|
if ipconf.PrivateIPAddress != nil {
|
|
netInfo.IpAddresses = append(netInfo.IpAddresses, *ipconf.PrivateIPAddress)
|
|
}
|
|
if ipconf.PublicIPAddress != nil {
|
|
if ipconf.PublicIPAddress.ID == nil {
|
|
return nil, fmt.Errorf("ip configuration %q has public IP address info but nil id", *ipconf.ID)
|
|
}
|
|
ipResGroup, ipName, err := splitId(*ipconf.PublicIPAddress.ID, constMsNetworkService, constPublicIpAddressesResource)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error splitting public ip address id %q: %w", *ipconf.PublicIPAddress.ID, err)
|
|
}
|
|
pipInfo, err := pipClient.Get(ctx, ipResGroup, ipName, "")
|
|
if err != nil {
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching public IP information with resource group %q and name %q: %w", ipResGroup, ipName, err)
|
|
}
|
|
}
|
|
if pipInfo.PublicIPAddressPropertiesFormat == nil {
|
|
return nil, fmt.Errorf("nil public ip address properties format for public ip %q", *ipconf.PublicIPAddress.ID)
|
|
}
|
|
if pipInfo.PublicIPAddressPropertiesFormat.IPAddress != nil {
|
|
netInfo.IpAddresses = append(netInfo.IpAddresses, *pipInfo.PublicIPAddressPropertiesFormat.IPAddress)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
vmToNetworkMap[vmId] = netInfo
|
|
}
|
|
}
|
|
|
|
ret := &pb.ListHostsResponse{}
|
|
for k, v := range vmToNetworkMap {
|
|
host := &pb.ListHostsResponseHost{
|
|
ExternalId: k,
|
|
IpAddresses: v.IpAddresses,
|
|
SetIds: resourceToSetMap[k],
|
|
}
|
|
ret.Hosts = append(ret.Hosts, host)
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func splitId(in, expectedService, expectedResource string) (string, string, error) {
|
|
// Note: this could be stolen from the helpers directory in the TF AzureRM
|
|
// provider but I'm not at the moment because it does a bunch of trickery
|
|
// for some Azure APIs and we have a much more limited scope. I may come to
|
|
// regret this.
|
|
splitId := strings.Split(strings.TrimLeft(in, "/"), "/")
|
|
// Run through some sanity checks
|
|
if len(splitId) != 8 ||
|
|
splitId[0] != constSubscriptions ||
|
|
splitId[2] != constResourceGroups ||
|
|
splitId[4] != constProviders ||
|
|
splitId[5] != expectedService ||
|
|
splitId[6] != expectedResource {
|
|
return "", "", fmt.Errorf("unexpected format of resource ID: %v", splitId)
|
|
}
|
|
return splitId[3], splitId[7], nil
|
|
}
|
|
|
|
func sanitizeSecretsFields(in *structpb.Struct) {
|
|
// Only we are allowed to set this value
|
|
if fields := in.GetFields(); fields != nil {
|
|
delete(fields, constCredsLastRotatedTime)
|
|
}
|
|
}
|