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) } }