1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

NEW PROVIDER: AkamaiEdgeDNS (#1174)

* downcase TLSA

* Akamai provider

* Akamai provider

* EdgeDNS provider

* AkamaiEdgeDNS provider

* AkamaiEdgeDNS provider

* AkamaiEdgeDNS provider

Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
Steven Vernick
2021-06-22 10:24:49 -04:00
committed by GitHub
parent 12ff5cff97
commit be1f03fb75
24 changed files with 857 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ package all
import (
// Define all known providers here. They should each register themselves with the providers package via init function.
_ "github.com/StackExchange/dnscontrol/v3/providers/activedir"
_ "github.com/StackExchange/dnscontrol/v3/providers/akamaiedgedns"
_ "github.com/StackExchange/dnscontrol/v3/providers/axfrddns"
_ "github.com/StackExchange/dnscontrol/v3/providers/azuredns"
_ "github.com/StackExchange/dnscontrol/v3/providers/bind"

View File

@@ -0,0 +1,247 @@
package akamaiedgedns
/*
Akamai Edge DNS provider
For information about Akamai Edge DNS, see:
https://www.akamai.com/us/en/products/security/edge-dns.jsp
https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html
https://www.akamai.com/us/en/multimedia/documents/product-brief/edge-dns-product-brief.pdf
*/
import (
"encoding/json"
"fmt"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
"github.com/StackExchange/dnscontrol/v3/pkg/txtutil"
"github.com/StackExchange/dnscontrol/v3/providers"
"strings"
)
var features = providers.DocumentationNotes{
// The default for unlisted capabilities is 'Cannot'.
// See providers/capabilities.go for the entire list of capabilties.
providers.CanUseAlias: providers.Cannot(),
providers.CanUseCAA: providers.Can(),
providers.CanUseDS: providers.Cannot(),
providers.CanUseDSForChildren: providers.Can(),
providers.CanUsePTR: providers.Can(),
providers.CanUseNAPTR: providers.Can(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSSHFP: providers.Can(),
providers.CanUseTLSA: providers.Can(),
providers.CanAutoDNSSEC: providers.Can(),
providers.CantUseNOPURGE: providers.Cannot(),
providers.DocOfficiallySupported: providers.Cannot(),
providers.DocDualHost: providers.Can(),
providers.CanUseSOA: providers.Cannot(),
providers.DocCreateDomains: providers.Can(),
providers.CanGetZones: providers.Can(),
providers.CanUseAKAMAICDN: providers.Can(),
}
type edgeDNSProvider struct {
contractID string
groupID string
}
func init() {
fns := providers.DspFuncs{
Initializer: newEdgeDNSDSP,
RecordAuditor: AuditRecords,
}
providers.RegisterDomainServiceProviderType("AKAMAIEDGEDNS", fns, features)
providers.RegisterCustomRecordType("AKAMAICDN", "AKAMAIEDGEDNS", "")
}
// DnsServiceProvider
func newEdgeDNSDSP(config map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
clientSecret := config["client_secret"]
host := config["host"]
accessToken := config["access_token"]
clientToken := config["client_token"]
contractID := config["contract_id"]
groupID := config["group_id"]
if clientSecret == "" {
return nil, fmt.Errorf("creds.json: client_secret must not be empty")
}
if host == "" {
return nil, fmt.Errorf("creds.json: host must not be empty")
}
if accessToken == "" {
return nil, fmt.Errorf("creds.json: accessToken must not be empty")
}
if clientToken == "" {
return nil, fmt.Errorf("creds.json: clientToken must not be empty")
}
if contractID == "" {
return nil, fmt.Errorf("creds.json: contractID must not be empty")
}
if groupID == "" {
return nil, fmt.Errorf("creds.json: groupID must not be empty")
}
initialize(clientSecret, host, accessToken, clientToken)
api := &edgeDNSProvider{
contractID: contractID,
groupID: groupID,
}
return api, nil
}
// EnsureDomainExists configures a new zone if the zone does not already exist.
func (a *edgeDNSProvider) EnsureDomainExists(domain string) error {
if zoneDoesExist(domain) {
printer.Debugf("Zone %s already exists\n", domain)
return nil
}
return createZone(domain, a.contractID, a.groupID)
}
// GetDomainCorrections return a list of corrections. Each correction is a text string describing the change
// and a function that, if called, will make the change.
// “dnscontrol preview” simply prints the text strings.
// "dnscontrol push" prints the strings and calls the functions.
func (a *edgeDNSProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
err := dc.Punycode()
if err != nil {
return nil, err
}
existingRecords, err := getRecords(dc.Name)
if err != nil {
return nil, err
}
models.PostProcessRecords(existingRecords)
txtutil.SplitSingleLongTxt(dc.Records)
keysToUpdate, err := (diff.New(dc)).ChangedGroups(existingRecords)
if err != nil {
return nil, err
}
existingRecordsMap := make(map[models.RecordKey][]*models.RecordConfig)
for _, r := range existingRecords {
key := models.RecordKey{NameFQDN: r.NameFQDN, Type: r.Type}
existingRecordsMap[key] = append(existingRecordsMap[key], r)
}
desiredRecordsMap := dc.Records.GroupedByKey()
// Deletes must occur first. For example, if replacing a existing CNAME with an A of the same name:
// DELETE CNAME foo.example.net
// must occur before
// CREATE A foo.example.net
// because both an A and a CNAME for the same name is not allowed.
corrections := []*models.Correction{} // deletes first
lastCorrections := []*models.Correction{} // creates and replaces last
for key, msg := range keysToUpdate {
existing, okExisting := existingRecordsMap[key]
desired, okDesired := desiredRecordsMap[key]
if okExisting && !okDesired {
// In the existing map but not in the desired map: Delete
corrections = append(corrections, &models.Correction{
Msg: strings.Join(msg, "\n "),
F: func() error {
return deleteRecordset(existing, dc.Name)
},
})
printer.Debugf("deleteRecordset: %s %s\n", key.NameFQDN, key.Type)
for _, rdata := range existing {
printer.Debugf(" Rdata: %s\n", rdata.GetTargetCombined())
}
} else if !okExisting && okDesired {
// Not in the existing map but in the desired map: Create
lastCorrections = append(lastCorrections, &models.Correction{
Msg: strings.Join(msg, "\n "),
F: func() error {
return createRecordset(desired, dc.Name)
},
})
printer.Debugf("createRecordset: %s %s\n", key.NameFQDN, key.Type)
for _, rdata := range desired {
printer.Debugf(" Rdata: %s\n", rdata.GetTargetCombined())
}
} else if okExisting && okDesired {
// In the existing map and in the desired map: Replace
lastCorrections = append(lastCorrections, &models.Correction{
Msg: strings.Join(msg, "\n "),
F: func() error {
return replaceRecordset(desired, dc.Name)
},
})
printer.Debugf("replaceRecordset: %s %s\n", key.NameFQDN, key.Type)
for _, rdata := range desired {
printer.Debugf(" Rdata: %s\n", rdata.GetTargetCombined())
}
}
}
// Deletes first, then creates and replaces
corrections = append(corrections, lastCorrections...)
// AutoDnsSec correction
existingAutoDNSSecEnabled, err := isAutoDNSSecEnabled(dc.Name)
if err != nil {
return nil, err
}
desiredAutoDNSSecEnabled := dc.AutoDNSSEC == "on"
if !existingAutoDNSSecEnabled && desiredAutoDNSSecEnabled {
// Existing false (disabled), Desired true (enabled)
corrections = append(corrections, &models.Correction{
Msg: "Enable AutoDnsSec\n",
F: func() error {
return autoDNSSecEnable(true, dc.Name)
},
})
printer.Debugf("autoDNSSecEnable: Enable AutoDnsSec for zone %s\n", dc.Name)
} else if existingAutoDNSSecEnabled && !desiredAutoDNSSecEnabled {
// Existing true (enabled), Desired false (disabled)
corrections = append(corrections, &models.Correction{
Msg: "Disable AutoDnsSec\n",
F: func() error {
return autoDNSSecEnable(false, dc.Name)
},
})
printer.Debugf("autoDNSSecEnable: Disable AutoDnsSec for zone %s\n", dc.Name)
}
return corrections, nil
}
// GetNameservers returns the nameservers for a domain.
func (a *edgeDNSProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
authorities, err := getAuthorities(a.contractID)
if err != nil {
return nil, err
}
return models.ToNameserversStripTD(authorities)
}
// GetZoneRecords returns an array of RecordConfig structs for a zone.
func (a *edgeDNSProvider) GetZoneRecords(domain string) (models.Records, error) {
records, err := getRecords(domain)
if err != nil {
return nil, err
}
return records, nil
}
// ListZones returns all DNS zones managed by this provider.
func (a *edgeDNSProvider) ListZones() ([]string, error) {
zones, err := listZones(a.contractID)
if err != nil {
return nil, err
}
return zones, nil
}

View File

@@ -0,0 +1,305 @@
package akamaiedgedns
/*
For information about Akamai's "Edge DNS Zone Management API", see:
https://developer.akamai.com/api/cloud_security/edge_dns_zone_management/v2.html
For information about "AkamaiOPEN-edgegrid-golang" library, see:
https://github.com/akamai/AkamaiOPEN-edgegrid-golang
*/
import (
"fmt"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
dnsv2 "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
)
// initialize initializes the "Akamai OPEN EdgeGrid" library
func initialize(clientSecret string, host string, accessToken string, clientToken string) {
eg := edgegrid.Config{
ClientSecret: clientSecret,
Host: host,
AccessToken: accessToken,
ClientToken: clientToken,
MaxBody: 131072,
Debug: false,
}
dnsv2.Init(eg)
}
// zoneDoesExist returns true if the zone exists, false otherwise.
func zoneDoesExist(zonename string) bool {
_, err := dnsv2.GetZone(zonename)
return err == nil
}
// createZone create a new zone and creates SOA and NS records for the zone.
// Akamai assigns a unique set of authoritative nameservers for each contract. These authorities should be
// used as the NS records on all zones belonging to this contract.
func createZone(zonename string, contractID string, groupID string) error {
zone := &dnsv2.ZoneCreate{
Zone: zonename,
Type: "PRIMARY",
Comment: "This zone created by DNSControl (http://dnscontrol.org)",
SignAndServe: false,
SignAndServeAlgorithm: "RSA_SHA512",
ContractId: contractID,
}
queryArgs := &dnsv2.ZoneQueryString{
Contract: contractID,
Group: groupID,
}
err := dnsv2.ValidateZone(zone)
if err != nil {
return fmt.Errorf("Invalid value provided for zone. Error: %s", err.Error())
}
err = zone.Save(*queryArgs)
if err != nil {
return fmt.Errorf("Zone create failed. Error: %s", err.Error())
}
// Indirectly create NS and SOA records
err = zone.SaveChangelist()
if err != nil {
return fmt.Errorf("Zone initialization failed. SOA and NS records need to be created")
}
err = zone.SubmitChangelist()
if err != nil {
return fmt.Errorf("Zone create failed. Error: %s", err.Error())
}
printer.Printf("Created zone: %s\n", zone.Zone)
printer.Printf(" Type: %s\n", zone.Type)
printer.Printf(" Comment: %s\n", zone.Comment)
printer.Printf(" SignAndServe: %v\n", zone.SignAndServe)
printer.Printf(" SignAndServeAlgorithm: %s\n", zone.SignAndServeAlgorithm)
printer.Printf(" ContractId: %s\n", zone.ContractId)
printer.Printf(" GroupId: %s\n", queryArgs.Group)
return nil
}
// listZones lists all zones associated with this contract.
func listZones(contractID string) ([]string, error) {
queryArgs := dnsv2.ZoneListQueryArgs{
ContractIds: contractID,
ShowAll: true,
}
zoneListResp, err := dnsv2.ListZones(queryArgs)
if err != nil {
return nil, fmt.Errorf("Zone List retrieval failed. Error: %s", err.Error())
}
edgeDNSZones := zoneListResp.Zones // what we have
var zones []string // what we return
for _, edgeDNSZone := range edgeDNSZones {
zones = append(zones, edgeDNSZone.Zone)
}
return zones, nil
}
// isAutoDNSSecEnabled returns true if AutoDNSSEC (SignAndServe) is enabled, false otherwise.
func isAutoDNSSecEnabled(zonename string) (bool, error) {
zone, err := dnsv2.GetZone(zonename)
if err != nil {
if dnsv2.IsConfigDNSError(err) && err.(dnsv2.ConfigDNSError).NotFound() {
return false, fmt.Errorf("Zone %s does not exist. Error: %s",
zonename, err.Error())
}
return false, fmt.Errorf("Error retrieving information for zone %s. Error: %s",
zonename, err.Error())
}
return zone.SignAndServe, nil
}
// autoDNSSecEnable enables or disables AutoDNSSEC (SignAndServe) for the zone.
func autoDNSSecEnable(enable bool, zonename string) error {
zone, err := dnsv2.GetZone(zonename)
if err != nil {
if dnsv2.IsConfigDNSError(err) && err.(dnsv2.ConfigDNSError).NotFound() {
return fmt.Errorf("Zone %s does not exist. Error: %s",
zonename, err.Error())
}
return fmt.Errorf("Error retrieving information for zone %s. Error: %s",
zonename, err.Error())
}
algorithm := "RSA_SHA512"
if zone.SignAndServeAlgorithm != "" {
algorithm = zone.SignAndServeAlgorithm
}
modifiedzone := &dnsv2.ZoneCreate{
Zone: zone.Zone,
Type: zone.Type,
Masters: zone.Masters,
Comment: zone.Comment,
SignAndServe: enable, // AutoDNSSEC
SignAndServeAlgorithm: algorithm,
TsigKey: zone.TsigKey,
EndCustomerId: zone.EndCustomerId,
ContractId: zone.ContractId,
}
queryArgs := dnsv2.ZoneQueryString{}
err = modifiedzone.Update(queryArgs)
if err != nil {
return fmt.Errorf("Error updating zone %s. Error: %s",
zonename, err.Error())
}
return nil
}
// getAuthorities returns the list of authoritative nameservers for the contract.
// Akamai assigns a unique set of authoritative nameservers for each contract. These authorities should be
// used as the NS records on all zones belonging to this contract.
func getAuthorities(contractID string) ([]string, error) {
authorityResponse, err := dnsv2.GetAuthorities(contractID)
if err != nil {
return nil, fmt.Errorf("getAuthorities - ContractID %s: Authorities retrieval failed. Error: %s",
contractID, err.Error())
}
contracts := authorityResponse.Contracts
if len(contracts) != 1 {
return nil, fmt.Errorf("getAuthorities - ContractID %s: Expected 1 element in array but got %d",
contractID, len(contracts))
}
cid := contracts[0].ContractID
if cid != contractID {
return nil, fmt.Errorf("getAuthorities - ContractID %s: Got authorities for wrong contractID (%s)",
contractID, cid)
}
authorities := contracts[0].Authorities
return authorities, nil
}
// rcToRs converts DNSControl RecordConfig records to an AkamaiEdgeDNS recordset.
func rcToRs(records []*models.RecordConfig, zonename string) (*dnsv2.RecordBody, error) {
if len(records) == 0 {
return nil, fmt.Errorf("No records to replace")
}
akaRecord := &dnsv2.RecordBody{
Name: records[0].NameFQDN,
RecordType: records[0].Type,
TTL: int(records[0].TTL),
}
for _, r := range records {
akaRecord.Target = append(akaRecord.Target, r.GetTargetCombined())
}
return akaRecord, nil
}
// createRecordset creates a new AkamaiEdgeDNS recordset in the zone.
func createRecordset(records []*models.RecordConfig, zonename string) error {
akaRecord, err := rcToRs(records, zonename)
if err != nil {
return err
}
err = akaRecord.Save(zonename, true)
if err != nil {
return fmt.Errorf("Recordset creation failed. Error: %s", err.Error())
}
return nil
}
// replaceRecordset replaces an existing AkamaiEdgeDNS recordset in the zone.
func replaceRecordset(records []*models.RecordConfig, zonename string) error {
akaRecord, err := rcToRs(records, zonename)
if err != nil {
return err
}
err = akaRecord.Update(zonename, true)
if err != nil {
return fmt.Errorf("Recordset update failed. Error: %s", err.Error())
}
return nil
}
// deleteRecordset deletes an existing AkamaiEdgeDNS recordset in the zone.
func deleteRecordset(records []*models.RecordConfig, zonename string) error {
akaRecord, err := rcToRs(records, zonename)
if err != nil {
return err
}
err = akaRecord.Delete(zonename, true)
if err != nil {
if dnsv2.IsConfigDNSError(err) && err.(dnsv2.ConfigDNSError).NotFound() {
return fmt.Errorf("Recordset not found")
}
return fmt.Errorf("Failed to delete recordset. Error: %s", err.Error())
}
return nil
}
/*
Example AkamaiEdgeDNS Recordset (as JSON):
{
"name": "test.com",
"rdata": [
"a7.akafp.net.",
"a4.akafp.net.",
"a0.akafp.net."
],
"ttl": 10000,
"type": "NS"
}
*/
// getRecords returns all RecordConfig records in the zone.
func getRecords(zonename string) ([]*models.RecordConfig, error) {
queryArgs := dnsv2.RecordsetQueryArgs{ShowAll: true}
rsetResp, err := dnsv2.GetRecordsets(zonename, queryArgs)
if err != nil {
return nil, fmt.Errorf("Recordset list retrieval failed. Error: %s", err.Error())
}
akaRecordsets := rsetResp.Recordsets // what we have
var recordConfigs []*models.RecordConfig // what we return
// For each AkamaiEdgeDNS recordset...
for _, akarecset := range akaRecordsets {
akaname := akarecset.Name
akatype := akarecset.Type
akattl := akarecset.TTL
// Don't report the existence of an SOA record (because DnsControl will try to delete the SOA record).
if akatype == "SOA" {
continue
}
// ... convert the recordset into 1 or more RecordConfig structs
for _, r := range akarecset.Rdata {
rc := &models.RecordConfig{
Type: akatype,
TTL: uint32(akattl),
}
rc.SetLabelFromFQDN(akaname, zonename)
err = rc.PopulateFromString(akatype, r, zonename)
if err != nil {
return nil, err
}
recordConfigs = append(recordConfigs, rc)
}
}
return recordConfigs, nil
}

View File

@@ -0,0 +1,8 @@
package akamaiedgedns
import "github.com/StackExchange/dnscontrol/v3/models"
// AuditRecords returns an error if any records are not supportable by this provider.
func AuditRecords(records []*models.RecordConfig) error {
return nil
}

View File

@@ -69,6 +69,9 @@ const (
// CanUseSOA indicates the provider supports full management of a zone's SOA record
CanUseSOA
// CanUseAKAMAICDN indicates the provider support the specific AKAMAICDN records that only the Akamai EdgeDns provider supports
CanUseAKAMAICDN
)
var providerCapabilities = map[string]map[Capability]bool{}