sigs.k8s.io/external-dns@v0.14.1/provider/bluecat/bluecat.go (about)

     1  /*
     2  Copyright 2020 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // TODO: Ensure we have proper error handling/logging for API calls to Bluecat. getBluecatGatewayToken has a good example of this
    18  // TODO: Remove studdering
    19  // TODO: Make API calls more consistent (eg error handling on HTTP response codes)
    20  // TODO: zone-id-filter does not seem to work with our provider
    21  
    22  package bluecat
    23  
    24  import (
    25  	"context"
    26  	"encoding/json"
    27  	"os"
    28  	"regexp"
    29  	"strconv"
    30  	"strings"
    31  
    32  	"github.com/pkg/errors"
    33  	log "github.com/sirupsen/logrus"
    34  
    35  	"sigs.k8s.io/external-dns/endpoint"
    36  	"sigs.k8s.io/external-dns/plan"
    37  	"sigs.k8s.io/external-dns/provider"
    38  	api "sigs.k8s.io/external-dns/provider/bluecat/gateway"
    39  )
    40  
    41  // BluecatProvider implements the DNS provider for Bluecat DNS
    42  type BluecatProvider struct {
    43  	provider.BaseProvider
    44  	domainFilter     endpoint.DomainFilter
    45  	zoneIDFilter     provider.ZoneIDFilter
    46  	dryRun           bool
    47  	RootZone         string
    48  	DNSConfiguration string
    49  	DNSServerName    string
    50  	DNSDeployType    string
    51  	View             string
    52  	gatewayClient    api.GatewayClient
    53  	TxtPrefix        string
    54  	TxtSuffix        string
    55  }
    56  
    57  type bluecatRecordSet struct {
    58  	obj interface{}
    59  	res interface{}
    60  }
    61  
    62  // NewBluecatProvider creates a new Bluecat provider.
    63  //
    64  // Returns a pointer to the provider or an error if a provider could not be created.
    65  func NewBluecatProvider(configFile, dnsConfiguration, dnsServerName, dnsDeployType, dnsView, gatewayHost, rootZone, txtPrefix, txtSuffix string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun, skipTLSVerify bool) (*BluecatProvider, error) {
    66  	cfg := api.BluecatConfig{}
    67  	contents, err := os.ReadFile(configFile)
    68  	if err != nil {
    69  		if errors.Is(err, os.ErrNotExist) {
    70  			cfg = api.BluecatConfig{
    71  				GatewayHost:      gatewayHost,
    72  				DNSConfiguration: dnsConfiguration,
    73  				DNSServerName:    dnsServerName,
    74  				DNSDeployType:    dnsDeployType,
    75  				View:             dnsView,
    76  				RootZone:         rootZone,
    77  				SkipTLSVerify:    skipTLSVerify,
    78  				GatewayUsername:  "",
    79  				GatewayPassword:  "",
    80  			}
    81  		} else {
    82  			return nil, errors.Wrapf(err, "failed to read Bluecat config file %v", configFile)
    83  		}
    84  	} else {
    85  		err = json.Unmarshal(contents, &cfg)
    86  		if err != nil {
    87  			return nil, errors.Wrapf(err, "failed to parse Bluecat JSON config file %v", configFile)
    88  		}
    89  	}
    90  
    91  	if !api.IsValidDNSDeployType(cfg.DNSDeployType) {
    92  		return nil, errors.Errorf("%v is not a valid deployment type", cfg.DNSDeployType)
    93  	}
    94  
    95  	token, cookie, err := api.GetBluecatGatewayToken(cfg)
    96  	if err != nil {
    97  		return nil, errors.Wrap(err, "failed to get API token from Bluecat Gateway")
    98  	}
    99  	gatewayClient := api.NewGatewayClientConfig(cookie, token, cfg.GatewayHost, cfg.DNSConfiguration, cfg.View, cfg.RootZone, cfg.DNSServerName, cfg.SkipTLSVerify)
   100  
   101  	provider := &BluecatProvider{
   102  		domainFilter:     domainFilter,
   103  		zoneIDFilter:     zoneIDFilter,
   104  		dryRun:           dryRun,
   105  		gatewayClient:    gatewayClient,
   106  		DNSConfiguration: cfg.DNSConfiguration,
   107  		DNSServerName:    cfg.DNSServerName,
   108  		DNSDeployType:    cfg.DNSDeployType,
   109  		View:             cfg.View,
   110  		RootZone:         cfg.RootZone,
   111  		TxtPrefix:        txtPrefix,
   112  		TxtSuffix:        txtSuffix,
   113  	}
   114  	return provider, nil
   115  }
   116  
   117  // Records fetches Host, CNAME, and TXT records from bluecat gateway
   118  func (p *BluecatProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, err error) {
   119  	zones, err := p.zones()
   120  	if err != nil {
   121  		return nil, errors.Wrap(err, "could not fetch zones")
   122  	}
   123  
   124  	// Parsing Text records first, so we can get the owner from them.
   125  	for _, zone := range zones {
   126  		log.Debugf("fetching records from zone '%s'", zone)
   127  
   128  		var resT []api.BluecatTXTRecord
   129  		err = p.gatewayClient.GetTXTRecords(zone, &resT)
   130  		if err != nil {
   131  			return nil, errors.Wrapf(err, "could not fetch TXT records for zone: %v", zone)
   132  		}
   133  		for _, rec := range resT {
   134  			tempEndpoint := endpoint.NewEndpoint(rec.Name, endpoint.RecordTypeTXT, rec.Properties)
   135  			tempEndpoint.Labels[endpoint.OwnerLabelKey], err = extractOwnerfromTXTRecord(rec.Properties)
   136  			if err != nil {
   137  				log.Debugf("External DNS Owner %s", err)
   138  			}
   139  			endpoints = append(endpoints, tempEndpoint)
   140  		}
   141  
   142  		var resH []api.BluecatHostRecord
   143  		err = p.gatewayClient.GetHostRecords(zone, &resH)
   144  		if err != nil {
   145  			return nil, errors.Wrapf(err, "could not fetch host records for zone: %v", zone)
   146  		}
   147  		var ep *endpoint.Endpoint
   148  		for _, rec := range resH {
   149  			propMap := api.SplitProperties(rec.Properties)
   150  			ips := strings.Split(propMap["addresses"], ",")
   151  			for _, ip := range ips {
   152  				if _, ok := propMap["ttl"]; ok {
   153  					ttl, err := strconv.Atoi(propMap["ttl"])
   154  					if err != nil {
   155  						return nil, errors.Wrapf(err, "could not parse ttl '%d' as int for host record %v", ttl, rec.Name)
   156  					}
   157  					ep = endpoint.NewEndpointWithTTL(propMap["absoluteName"], endpoint.RecordTypeA, endpoint.TTL(ttl), ip)
   158  				} else {
   159  					ep = endpoint.NewEndpoint(propMap["absoluteName"], endpoint.RecordTypeA, ip)
   160  				}
   161  				for _, txtRec := range resT {
   162  					if strings.Compare(p.TxtPrefix+rec.Name+p.TxtSuffix, txtRec.Name) == 0 {
   163  						ep.Labels[endpoint.OwnerLabelKey], err = extractOwnerfromTXTRecord(txtRec.Properties)
   164  						if err != nil {
   165  							log.Debugf("External DNS Owner %s", err)
   166  						}
   167  					}
   168  				}
   169  				endpoints = append(endpoints, ep)
   170  			}
   171  		}
   172  
   173  		var resC []api.BluecatCNAMERecord
   174  		err = p.gatewayClient.GetCNAMERecords(zone, &resC)
   175  		if err != nil {
   176  			return nil, errors.Wrapf(err, "could not fetch CNAME records for zone: %v", zone)
   177  		}
   178  
   179  		for _, rec := range resC {
   180  			propMap := api.SplitProperties(rec.Properties)
   181  			if _, ok := propMap["ttl"]; ok {
   182  				ttl, err := strconv.Atoi(propMap["ttl"])
   183  				if err != nil {
   184  					return nil, errors.Wrapf(err, "could not parse ttl '%d' as int for CNAME record %v", ttl, rec.Name)
   185  				}
   186  				ep = endpoint.NewEndpointWithTTL(propMap["absoluteName"], endpoint.RecordTypeCNAME, endpoint.TTL(ttl), propMap["linkedRecordName"])
   187  			} else {
   188  				ep = endpoint.NewEndpoint(propMap["absoluteName"], endpoint.RecordTypeCNAME, propMap["linkedRecordName"])
   189  			}
   190  			for _, txtRec := range resT {
   191  				if strings.Compare(p.TxtPrefix+rec.Name+p.TxtSuffix, txtRec.Name) == 0 {
   192  					ep.Labels[endpoint.OwnerLabelKey], err = extractOwnerfromTXTRecord(txtRec.Properties)
   193  					if err != nil {
   194  						log.Debugf("External DNS Owner %s", err)
   195  					}
   196  				}
   197  			}
   198  			endpoints = append(endpoints, ep)
   199  		}
   200  	}
   201  
   202  	log.Debugf("fetched %d records from Bluecat", len(endpoints))
   203  	return endpoints, nil
   204  }
   205  
   206  // ApplyChanges updates necessary zones and replaces old records with new ones
   207  //
   208  // Returns nil upon success and err is there is an error
   209  func (p *BluecatProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   210  	zones, err := p.zones()
   211  	if err != nil {
   212  		return err
   213  	}
   214  	log.Infof("zones is: %+v\n", zones)
   215  	log.Infof("changes: %+v\n", changes)
   216  	created, deleted := p.mapChanges(zones, changes)
   217  	log.Infof("created: %+v\n", created)
   218  	log.Infof("deleted: %+v\n", deleted)
   219  	p.deleteRecords(deleted)
   220  	p.createRecords(created)
   221  
   222  	if p.DNSServerName != "" {
   223  		if p.dryRun {
   224  			log.Debug("Not executing deploy because this is running in dry-run mode")
   225  		} else {
   226  			switch p.DNSDeployType {
   227  			case "full-deploy":
   228  				err := p.gatewayClient.ServerFullDeploy()
   229  				if err != nil {
   230  					return err
   231  				}
   232  			case "no-deploy":
   233  				log.Debug("Not executing deploy because DNSDeployType is set to 'no-deploy'")
   234  			}
   235  		}
   236  	} else {
   237  		log.Debug("Not executing deploy because server name was not provided")
   238  	}
   239  
   240  	return nil
   241  }
   242  
   243  type bluecatChangeMap map[string][]*endpoint.Endpoint
   244  
   245  func (p *BluecatProvider) mapChanges(zones []string, changes *plan.Changes) (bluecatChangeMap, bluecatChangeMap) {
   246  	created := bluecatChangeMap{}
   247  	deleted := bluecatChangeMap{}
   248  
   249  	mapChange := func(changeMap bluecatChangeMap, change *endpoint.Endpoint) {
   250  		zone := p.findZone(zones, change.DNSName)
   251  		if zone == "" {
   252  			log.Debugf("ignoring changes to '%s' because a suitable Bluecat DNS zone was not found", change.DNSName)
   253  			return
   254  		}
   255  		changeMap[zone] = append(changeMap[zone], change)
   256  	}
   257  
   258  	for _, change := range changes.Delete {
   259  		mapChange(deleted, change)
   260  	}
   261  	for _, change := range changes.UpdateOld {
   262  		mapChange(deleted, change)
   263  	}
   264  	for _, change := range changes.Create {
   265  		mapChange(created, change)
   266  	}
   267  	for _, change := range changes.UpdateNew {
   268  		mapChange(created, change)
   269  	}
   270  
   271  	return created, deleted
   272  }
   273  
   274  // findZone finds the most specific matching zone for a given record 'name' from a list of all zones
   275  func (p *BluecatProvider) findZone(zones []string, name string) string {
   276  	var result string
   277  
   278  	for _, zone := range zones {
   279  		if strings.HasSuffix(name, "."+zone) {
   280  			if result == "" || len(zone) > len(result) {
   281  				result = zone
   282  			}
   283  		} else if strings.EqualFold(name, zone) {
   284  			if result == "" || len(zone) > len(result) {
   285  				result = zone
   286  			}
   287  		}
   288  	}
   289  
   290  	return result
   291  }
   292  
   293  func (p *BluecatProvider) zones() ([]string, error) {
   294  	log.Debugf("retrieving Bluecat zones for configuration: %s, view: %s", p.DNSConfiguration, p.View)
   295  	var zones []string
   296  
   297  	zonelist, err := p.gatewayClient.GetBluecatZones(p.RootZone)
   298  	if err != nil {
   299  		return nil, err
   300  	}
   301  
   302  	for _, zone := range zonelist {
   303  		if !p.domainFilter.Match(zone.Name) {
   304  			continue
   305  		}
   306  
   307  		// TODO: match to absoluteName(string) not Id(int)
   308  		if !p.zoneIDFilter.Match(strconv.Itoa(zone.ID)) {
   309  			continue
   310  		}
   311  
   312  		zoneProps := api.SplitProperties(zone.Properties)
   313  
   314  		zones = append(zones, zoneProps["absoluteName"])
   315  	}
   316  	log.Debugf("found %d zones", len(zones))
   317  	return zones, nil
   318  }
   319  
   320  func (p *BluecatProvider) createRecords(created bluecatChangeMap) {
   321  	for zone, endpoints := range created {
   322  		for _, ep := range endpoints {
   323  			if p.dryRun {
   324  				log.Infof("would create %s record named '%s' to '%s' for Bluecat DNS zone '%s'.",
   325  					ep.RecordType,
   326  					ep.DNSName,
   327  					ep.Targets,
   328  					zone,
   329  				)
   330  				continue
   331  			}
   332  
   333  			log.Infof("creating %s record named '%s' to '%s' for Bluecat DNS zone '%s'.",
   334  				ep.RecordType,
   335  				ep.DNSName,
   336  				ep.Targets,
   337  				zone,
   338  			)
   339  
   340  			recordSet, err := p.recordSet(ep, false)
   341  			if err != nil {
   342  				log.Errorf(
   343  					"Failed to retrieve %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v",
   344  					ep.RecordType,
   345  					ep.DNSName,
   346  					ep.Targets,
   347  					zone,
   348  					err,
   349  				)
   350  				continue
   351  			}
   352  			var response interface{}
   353  			switch ep.RecordType {
   354  			case endpoint.RecordTypeA:
   355  				err = p.gatewayClient.CreateHostRecord(zone, recordSet.obj.(*api.BluecatCreateHostRecordRequest))
   356  			case endpoint.RecordTypeCNAME:
   357  				err = p.gatewayClient.CreateCNAMERecord(zone, recordSet.obj.(*api.BluecatCreateCNAMERecordRequest))
   358  			case endpoint.RecordTypeTXT:
   359  				err = p.gatewayClient.CreateTXTRecord(zone, recordSet.obj.(*api.BluecatCreateTXTRecordRequest))
   360  			}
   361  			log.Debugf("Response from create: %v", response)
   362  			if err != nil {
   363  				log.Errorf(
   364  					"Failed to create %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v",
   365  					ep.RecordType,
   366  					ep.DNSName,
   367  					ep.Targets,
   368  					zone,
   369  					err,
   370  				)
   371  			}
   372  		}
   373  	}
   374  }
   375  
   376  func (p *BluecatProvider) deleteRecords(deleted bluecatChangeMap) {
   377  	// run deletions first
   378  	for zone, endpoints := range deleted {
   379  		for _, ep := range endpoints {
   380  			if p.dryRun {
   381  				log.Infof("would delete %s record named '%s' for Bluecat DNS zone '%s'.",
   382  					ep.RecordType,
   383  					ep.DNSName,
   384  					zone,
   385  				)
   386  				continue
   387  			} else {
   388  				log.Infof("deleting %s record named '%s' for Bluecat DNS zone '%s'.",
   389  					ep.RecordType,
   390  					ep.DNSName,
   391  					zone,
   392  				)
   393  
   394  				recordSet, err := p.recordSet(ep, true)
   395  				if err != nil {
   396  					log.Errorf(
   397  						"Failed to retrieve %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v",
   398  						ep.RecordType,
   399  						ep.DNSName,
   400  						ep.Targets,
   401  						zone,
   402  						err,
   403  					)
   404  					continue
   405  				}
   406  
   407  				switch ep.RecordType {
   408  				case endpoint.RecordTypeA:
   409  					for _, record := range *recordSet.res.(*[]api.BluecatHostRecord) {
   410  						err = p.gatewayClient.DeleteHostRecord(record.Name, zone)
   411  					}
   412  				case endpoint.RecordTypeCNAME:
   413  					for _, record := range *recordSet.res.(*[]api.BluecatCNAMERecord) {
   414  						err = p.gatewayClient.DeleteCNAMERecord(record.Name, zone)
   415  					}
   416  				case endpoint.RecordTypeTXT:
   417  					for _, record := range *recordSet.res.(*[]api.BluecatTXTRecord) {
   418  						err = p.gatewayClient.DeleteTXTRecord(record.Name, zone)
   419  					}
   420  				}
   421  				if err != nil {
   422  					log.Errorf("Failed to delete %s record named '%s' for Bluecat DNS zone '%s': %v",
   423  						ep.RecordType,
   424  						ep.DNSName,
   425  						zone,
   426  						err)
   427  				}
   428  			}
   429  		}
   430  	}
   431  }
   432  
   433  func (p *BluecatProvider) recordSet(ep *endpoint.Endpoint, getObject bool) (bluecatRecordSet, error) {
   434  	recordSet := bluecatRecordSet{}
   435  	switch ep.RecordType {
   436  	case endpoint.RecordTypeA:
   437  		var res []api.BluecatHostRecord
   438  		obj := api.BluecatCreateHostRecordRequest{
   439  			AbsoluteName: ep.DNSName,
   440  			IP4Address:   ep.Targets[0],
   441  			TTL:          int(ep.RecordTTL),
   442  			Properties:   "",
   443  		}
   444  		if getObject {
   445  			var record api.BluecatHostRecord
   446  			err := p.gatewayClient.GetHostRecord(ep.DNSName, &record)
   447  			if err != nil {
   448  				return bluecatRecordSet{}, err
   449  			}
   450  			res = append(res, record)
   451  		}
   452  		recordSet = bluecatRecordSet{
   453  			obj: &obj,
   454  			res: &res,
   455  		}
   456  	case endpoint.RecordTypeCNAME:
   457  		var res []api.BluecatCNAMERecord
   458  		obj := api.BluecatCreateCNAMERecordRequest{
   459  			AbsoluteName: ep.DNSName,
   460  			LinkedRecord: ep.Targets[0],
   461  			TTL:          int(ep.RecordTTL),
   462  			Properties:   "",
   463  		}
   464  		if getObject {
   465  			var record api.BluecatCNAMERecord
   466  			err := p.gatewayClient.GetCNAMERecord(ep.DNSName, &record)
   467  			if err != nil {
   468  				return bluecatRecordSet{}, err
   469  			}
   470  			res = append(res, record)
   471  		}
   472  		recordSet = bluecatRecordSet{
   473  			obj: &obj,
   474  			res: &res,
   475  		}
   476  	case endpoint.RecordTypeTXT:
   477  		var res []api.BluecatTXTRecord
   478  		// TODO: Allow setting TTL
   479  		// This is not implemented in the Bluecat Gateway
   480  		obj := api.BluecatCreateTXTRecordRequest{
   481  			AbsoluteName: ep.DNSName,
   482  			Text:         ep.Targets[0],
   483  		}
   484  		if getObject {
   485  			var record api.BluecatTXTRecord
   486  			err := p.gatewayClient.GetTXTRecord(ep.DNSName, &record)
   487  			if err != nil {
   488  				return bluecatRecordSet{}, err
   489  			}
   490  			res = append(res, record)
   491  		}
   492  		recordSet = bluecatRecordSet{
   493  			obj: &obj,
   494  			res: &res,
   495  		}
   496  	}
   497  	return recordSet, nil
   498  }
   499  
   500  // extractOwnerFromTXTRecord takes a single text property string and returns the owner after parsing the owner string.
   501  func extractOwnerfromTXTRecord(propString string) (string, error) {
   502  	if len(propString) == 0 {
   503  		return "", errors.Errorf("External-DNS Owner not found")
   504  	}
   505  	re := regexp.MustCompile(`external-dns/owner=[^,]+`)
   506  	match := re.FindStringSubmatch(propString)
   507  	if len(match) == 0 {
   508  		return "", errors.Errorf("External-DNS Owner not found, %s", propString)
   509  	}
   510  	return strings.Split(match[0], "=")[1], nil
   511  }