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 }