github.com/teknogeek/dnscontrol/v2@v2.10.1-0.20200227202244-ae299b55ba42/providers/digitalocean/digitaloceanProvider.go (about)

     1  package digitalocean
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"net/http"
     8  
     9  	"github.com/StackExchange/dnscontrol/v2/models"
    10  	"github.com/StackExchange/dnscontrol/v2/providers"
    11  	"github.com/StackExchange/dnscontrol/v2/providers/diff"
    12  	"github.com/miekg/dns/dnsutil"
    13  
    14  	"github.com/digitalocean/godo"
    15  	"golang.org/x/oauth2"
    16  )
    17  
    18  /*
    19  
    20  DigitalOcean API DNS provider:
    21  
    22  Info required in `creds.json`:
    23     - token
    24  
    25  */
    26  
    27  // DoApi is the handle for operations.
    28  type DoApi struct {
    29  	client *godo.Client
    30  }
    31  
    32  var defaultNameServerNames = []string{
    33  	"ns1.digitalocean.com",
    34  	"ns2.digitalocean.com",
    35  	"ns3.digitalocean.com",
    36  }
    37  
    38  // NewDo creates a DO-specific DNS provider.
    39  func NewDo(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
    40  	if m["token"] == "" {
    41  		return nil, fmt.Errorf("no DigitalOcean token provided")
    42  	}
    43  
    44  	ctx := context.Background()
    45  	oauthClient := oauth2.NewClient(
    46  		ctx,
    47  		oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m["token"]}),
    48  	)
    49  	client := godo.NewClient(oauthClient)
    50  
    51  	api := &DoApi{client: client}
    52  
    53  	// Get a domain to validate the token
    54  	_, resp, err := api.client.Domains.List(ctx, &godo.ListOptions{PerPage: 1})
    55  	if err != nil {
    56  		return nil, err
    57  	}
    58  	if resp.StatusCode != http.StatusOK {
    59  		return nil, fmt.Errorf("token for digitalocean is not valid")
    60  	}
    61  
    62  	return api, nil
    63  }
    64  
    65  var features = providers.DocumentationNotes{
    66  	providers.DocCreateDomains:       providers.Can(),
    67  	providers.DocOfficiallySupported: providers.Cannot(),
    68  	providers.CanUseSRV:              providers.Can(),
    69  	// Digitalocean support CAA records, except
    70  	// ";" value with issue/issuewild records:
    71  	// https://www.digitalocean.com/docs/networking/dns/how-to/create-caa-records/
    72  	providers.CanUseCAA:   providers.Can(),
    73  	providers.CanGetZones: providers.Can(),
    74  }
    75  
    76  func init() {
    77  	providers.RegisterDomainServiceProviderType("DIGITALOCEAN", NewDo, features)
    78  }
    79  
    80  // EnsureDomainExists returns an error if domain doesn't exist.
    81  func (api *DoApi) EnsureDomainExists(domain string) error {
    82  	ctx := context.Background()
    83  	_, resp, err := api.client.Domains.Get(ctx, domain)
    84  	if resp.StatusCode == http.StatusNotFound {
    85  		_, _, err := api.client.Domains.Create(ctx, &godo.DomainCreateRequest{
    86  			Name:      domain,
    87  			IPAddress: "",
    88  		})
    89  		return err
    90  	}
    91  	return err
    92  }
    93  
    94  // GetNameservers returns the nameservers for domain.
    95  func (api *DoApi) GetNameservers(domain string) ([]*models.Nameserver, error) {
    96  	return models.StringsToNameservers(defaultNameServerNames), nil
    97  }
    98  
    99  // GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
   100  func (api *DoApi) GetZoneRecords(domain string) (models.Records, error) {
   101  	records, err := getRecords(api, domain)
   102  	if err != nil {
   103  		return nil, err
   104  	}
   105  
   106  	var existingRecords []*models.RecordConfig
   107  	for i := range records {
   108  		r := toRc(domain, &records[i])
   109  		if r.Type == "SOA" {
   110  			continue
   111  		}
   112  		existingRecords = append(existingRecords, r)
   113  	}
   114  
   115  	return existingRecords, nil
   116  }
   117  
   118  // GetDomainCorrections returns a list of corretions for the  domain.
   119  func (api *DoApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
   120  	ctx := context.Background()
   121  	dc.Punycode()
   122  
   123  	existingRecords, err := api.GetZoneRecords(dc.Name)
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	// Normalize
   129  	models.PostProcessRecords(existingRecords)
   130  
   131  	differ := diff.New(dc)
   132  	_, create, delete, modify := differ.IncrementalDiff(existingRecords)
   133  
   134  	var corrections = []*models.Correction{}
   135  
   136  	// Deletes first so changing type works etc.
   137  	for _, m := range delete {
   138  		id := m.Existing.Original.(*godo.DomainRecord).ID
   139  		corr := &models.Correction{
   140  			Msg: fmt.Sprintf("%s, DO ID: %d", m.String(), id),
   141  			F: func() error {
   142  				_, err := api.client.Domains.DeleteRecord(ctx, dc.Name, id)
   143  				return err
   144  			},
   145  		}
   146  		corrections = append(corrections, corr)
   147  	}
   148  	for _, m := range create {
   149  		req := toReq(dc, m.Desired)
   150  		corr := &models.Correction{
   151  			Msg: m.String(),
   152  			F: func() error {
   153  				_, _, err := api.client.Domains.CreateRecord(ctx, dc.Name, req)
   154  				return err
   155  			},
   156  		}
   157  		corrections = append(corrections, corr)
   158  	}
   159  	for _, m := range modify {
   160  		id := m.Existing.Original.(*godo.DomainRecord).ID
   161  		req := toReq(dc, m.Desired)
   162  		corr := &models.Correction{
   163  			Msg: fmt.Sprintf("%s, DO ID: %d", m.String(), id),
   164  			F: func() error {
   165  				_, _, err := api.client.Domains.EditRecord(ctx, dc.Name, id, req)
   166  				return err
   167  			},
   168  		}
   169  		corrections = append(corrections, corr)
   170  	}
   171  
   172  	return corrections, nil
   173  }
   174  
   175  func getRecords(api *DoApi, name string) ([]godo.DomainRecord, error) {
   176  	ctx := context.Background()
   177  
   178  	records := []godo.DomainRecord{}
   179  	opt := &godo.ListOptions{}
   180  	for {
   181  		result, resp, err := api.client.Domains.Records(ctx, name, opt)
   182  		if err != nil {
   183  			return nil, err
   184  		}
   185  
   186  		for _, d := range result {
   187  			records = append(records, d)
   188  		}
   189  
   190  		if resp.Links == nil || resp.Links.IsLastPage() {
   191  			break
   192  		}
   193  
   194  		page, err := resp.Links.CurrentPage()
   195  		if err != nil {
   196  			return nil, err
   197  		}
   198  
   199  		opt.Page = page + 1
   200  	}
   201  
   202  	return records, nil
   203  }
   204  
   205  func toRc(domain string, r *godo.DomainRecord) *models.RecordConfig {
   206  	// This handles "@" etc.
   207  	name := dnsutil.AddOrigin(r.Name, domain)
   208  
   209  	target := r.Data
   210  	// Make target FQDN (#rtype_variations)
   211  	if r.Type == "CNAME" || r.Type == "MX" || r.Type == "NS" || r.Type == "SRV" {
   212  		// If target is the domainname, e.g. cname foo.example.com -> example.com,
   213  		// DO returns "@" on read even if fqdn was written.
   214  		if target == "@" {
   215  			target = domain
   216  		} else if target == "." {
   217  			target = ""
   218  		}
   219  		target = target + "."
   220  	}
   221  
   222  	t := &models.RecordConfig{
   223  		Type:         r.Type,
   224  		TTL:          uint32(r.TTL),
   225  		MxPreference: uint16(r.Priority),
   226  		SrvPriority:  uint16(r.Priority),
   227  		SrvWeight:    uint16(r.Weight),
   228  		SrvPort:      uint16(r.Port),
   229  		Original:     r,
   230  		CaaTag:       r.Tag,
   231  		CaaFlag:      uint8(r.Flags),
   232  	}
   233  	t.SetLabelFromFQDN(name, domain)
   234  	t.SetTarget(target)
   235  	switch rtype := r.Type; rtype {
   236  	case "TXT":
   237  		t.SetTargetTXTString(target)
   238  	default:
   239  		// nothing additional required
   240  	}
   241  	return t
   242  }
   243  
   244  func toReq(dc *models.DomainConfig, rc *models.RecordConfig) *godo.DomainRecordEditRequest {
   245  	name := rc.GetLabel()         // DO wants the short name or "@" for apex.
   246  	target := rc.GetTargetField() // DO uses the target field only for a single value
   247  	priority := 0                 // DO uses the same property for MX and SRV priority
   248  
   249  	switch rc.Type { // #rtype_variations
   250  	case "MX":
   251  		priority = int(rc.MxPreference)
   252  	case "SRV":
   253  		priority = int(rc.SrvPriority)
   254  	case "TXT":
   255  		// TXT records are the one place where DO combines many items into one field.
   256  		target = rc.GetTargetCombined()
   257  	case "CAA":
   258  		// DO API requires that value ends in dot
   259  		// But the value returned from API doesn't contain this,
   260  		// so no need to strip the dot when reading value from API.
   261  		target = target + "."
   262  	default:
   263  		// no action required
   264  	}
   265  
   266  	return &godo.DomainRecordEditRequest{
   267  		Type:     rc.Type,
   268  		Name:     name,
   269  		Data:     target,
   270  		TTL:      int(rc.TTL),
   271  		Priority: priority,
   272  		Port:     int(rc.SrvPort),
   273  		Weight:   int(rc.SrvWeight),
   274  		Tag:      rc.CaaTag,
   275  		Flags:    int(rc.CaaFlag),
   276  	}
   277  }