mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-05-11 05:55:12 +00:00
This should enable the diff2 code to be inserted with good "git blame" results for new code. I'm adding this early to catch any problems early.
349 lines
8.4 KiB
Go
349 lines
8.4 KiB
Go
package digitalocean
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/StackExchange/dnscontrol/v3/models"
|
|
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
|
|
"github.com/StackExchange/dnscontrol/v3/pkg/diff2"
|
|
"github.com/StackExchange/dnscontrol/v3/pkg/txtutil"
|
|
"github.com/StackExchange/dnscontrol/v3/providers"
|
|
"github.com/digitalocean/godo"
|
|
"github.com/miekg/dns/dnsutil"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
/*
|
|
|
|
DigitalOcean API DNS provider:
|
|
|
|
Info required in `creds.json`:
|
|
- token
|
|
|
|
*/
|
|
|
|
// digitaloceanProvider is the handle for operations.
|
|
type digitaloceanProvider struct {
|
|
client *godo.Client
|
|
}
|
|
|
|
var defaultNameServerNames = []string{
|
|
"ns1.digitalocean.com",
|
|
"ns2.digitalocean.com",
|
|
"ns3.digitalocean.com",
|
|
}
|
|
|
|
const perPageSize = 100
|
|
|
|
// NewDo creates a DO-specific DNS provider.
|
|
func NewDo(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
|
|
if m["token"] == "" {
|
|
return nil, fmt.Errorf("no DigitalOcean token provided")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
oauthClient := oauth2.NewClient(
|
|
ctx,
|
|
oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m["token"]}),
|
|
)
|
|
client := godo.NewClient(oauthClient)
|
|
|
|
api := &digitaloceanProvider{client: client}
|
|
|
|
// Get a domain to validate the token
|
|
retry:
|
|
_, resp, err := api.client.Domains.List(ctx, &godo.ListOptions{PerPage: 1})
|
|
if err != nil {
|
|
if pauseAndRetry(resp) {
|
|
goto retry
|
|
}
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token for digitalocean is not valid")
|
|
}
|
|
|
|
return api, nil
|
|
}
|
|
|
|
var features = providers.DocumentationNotes{
|
|
providers.CanGetZones: providers.Can(),
|
|
providers.CanUseCAA: providers.Can(),
|
|
providers.CanUseSRV: providers.Can(),
|
|
providers.DocCreateDomains: providers.Can(),
|
|
providers.DocOfficiallySupported: providers.Cannot(),
|
|
}
|
|
|
|
func init() {
|
|
fns := providers.DspFuncs{
|
|
Initializer: NewDo,
|
|
RecordAuditor: AuditRecords,
|
|
}
|
|
providers.RegisterDomainServiceProviderType("DIGITALOCEAN", fns, features)
|
|
}
|
|
|
|
// EnsureDomainExists returns an error if domain doesn't exist.
|
|
func (api *digitaloceanProvider) EnsureDomainExists(domain string) error {
|
|
retry:
|
|
ctx := context.Background()
|
|
_, resp, err := api.client.Domains.Get(ctx, domain)
|
|
if err != nil {
|
|
if pauseAndRetry(resp) {
|
|
goto retry
|
|
}
|
|
//return err
|
|
}
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
_, _, err := api.client.Domains.Create(ctx, &godo.DomainCreateRequest{
|
|
Name: domain,
|
|
IPAddress: "",
|
|
})
|
|
return err
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetNameservers returns the nameservers for domain.
|
|
func (api *digitaloceanProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
|
|
return models.ToNameservers(defaultNameServerNames)
|
|
}
|
|
|
|
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
|
|
func (api *digitaloceanProvider) GetZoneRecords(domain string) (models.Records, error) {
|
|
records, err := getRecords(api, domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var existingRecords []*models.RecordConfig
|
|
for i := range records {
|
|
r := toRc(domain, &records[i])
|
|
if r.Type == "SOA" {
|
|
continue
|
|
}
|
|
existingRecords = append(existingRecords, r)
|
|
}
|
|
|
|
return existingRecords, nil
|
|
}
|
|
|
|
// GetDomainCorrections returns a list of corretions for the domain.
|
|
func (api *digitaloceanProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
|
|
ctx := context.Background()
|
|
dc.Punycode()
|
|
|
|
existingRecords, err := api.GetZoneRecords(dc.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Normalize
|
|
models.PostProcessRecords(existingRecords)
|
|
txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records
|
|
|
|
var corrections []*models.Correction
|
|
if !diff2.EnableDiff2 || true { // Remove "|| true" when diff2 version arrives
|
|
|
|
differ := diff.New(dc)
|
|
_, create, delete, modify, err := differ.IncrementalDiff(existingRecords)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Deletes first so changing type works etc.
|
|
for _, m := range delete {
|
|
id := m.Existing.Original.(*godo.DomainRecord).ID
|
|
corr := &models.Correction{
|
|
Msg: fmt.Sprintf("%s, DO ID: %d", m.String(), id),
|
|
F: func() error {
|
|
retry:
|
|
resp, err := api.client.Domains.DeleteRecord(ctx, dc.Name, id)
|
|
if err != nil {
|
|
if pauseAndRetry(resp) {
|
|
goto retry
|
|
}
|
|
}
|
|
return err
|
|
},
|
|
}
|
|
corrections = append(corrections, corr)
|
|
}
|
|
for _, m := range create {
|
|
req := toReq(dc, m.Desired)
|
|
corr := &models.Correction{
|
|
Msg: m.String(),
|
|
F: func() error {
|
|
retry:
|
|
_, resp, err := api.client.Domains.CreateRecord(ctx, dc.Name, req)
|
|
if err != nil {
|
|
if pauseAndRetry(resp) {
|
|
goto retry
|
|
}
|
|
}
|
|
return err
|
|
},
|
|
}
|
|
corrections = append(corrections, corr)
|
|
}
|
|
for _, m := range modify {
|
|
id := m.Existing.Original.(*godo.DomainRecord).ID
|
|
req := toReq(dc, m.Desired)
|
|
corr := &models.Correction{
|
|
Msg: fmt.Sprintf("%s, DO ID: %d", m.String(), id),
|
|
F: func() error {
|
|
retry:
|
|
_, resp, err := api.client.Domains.EditRecord(ctx, dc.Name, id, req)
|
|
if err != nil {
|
|
if pauseAndRetry(resp) {
|
|
goto retry
|
|
}
|
|
}
|
|
return err
|
|
},
|
|
}
|
|
corrections = append(corrections, corr)
|
|
}
|
|
|
|
return corrections, nil
|
|
}
|
|
|
|
// Insert Future diff2 version here.
|
|
|
|
return corrections, nil
|
|
}
|
|
|
|
func getRecords(api *digitaloceanProvider, name string) ([]godo.DomainRecord, error) {
|
|
ctx := context.Background()
|
|
|
|
retry:
|
|
|
|
records := []godo.DomainRecord{}
|
|
opt := &godo.ListOptions{PerPage: perPageSize}
|
|
for {
|
|
result, resp, err := api.client.Domains.Records(ctx, name, opt)
|
|
if err != nil {
|
|
if pauseAndRetry(resp) {
|
|
goto retry
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
records = append(records, result...)
|
|
|
|
if resp.Links == nil || resp.Links.IsLastPage() {
|
|
break
|
|
}
|
|
|
|
page, err := resp.Links.CurrentPage()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
opt.Page = page + 1
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
func toRc(domain string, r *godo.DomainRecord) *models.RecordConfig {
|
|
// This handles "@" etc.
|
|
name := dnsutil.AddOrigin(r.Name, domain)
|
|
|
|
target := r.Data
|
|
// Make target FQDN (#rtype_variations)
|
|
if r.Type == "CNAME" || r.Type == "MX" || r.Type == "NS" || r.Type == "SRV" {
|
|
// If target is the domainname, e.g. cname foo.example.com -> example.com,
|
|
// DO returns "@" on read even if fqdn was written.
|
|
if target == "@" {
|
|
target = domain
|
|
} else if target == "." {
|
|
target = ""
|
|
}
|
|
target = target + "."
|
|
}
|
|
|
|
t := &models.RecordConfig{
|
|
Type: r.Type,
|
|
TTL: uint32(r.TTL),
|
|
MxPreference: uint16(r.Priority),
|
|
SrvPriority: uint16(r.Priority),
|
|
SrvWeight: uint16(r.Weight),
|
|
SrvPort: uint16(r.Port),
|
|
Original: r,
|
|
CaaTag: r.Tag,
|
|
CaaFlag: uint8(r.Flags),
|
|
}
|
|
t.SetLabelFromFQDN(name, domain)
|
|
t.SetTarget(target)
|
|
switch rtype := r.Type; rtype {
|
|
case "TXT":
|
|
t.SetTargetTXTString(target)
|
|
default:
|
|
// nothing additional required
|
|
}
|
|
return t
|
|
}
|
|
|
|
func toReq(dc *models.DomainConfig, rc *models.RecordConfig) *godo.DomainRecordEditRequest {
|
|
name := rc.GetLabel() // DO wants the short name or "@" for apex.
|
|
target := rc.GetTargetField() // DO uses the target field only for a single value
|
|
priority := 0 // DO uses the same property for MX and SRV priority
|
|
|
|
switch rc.Type { // #rtype_variations
|
|
case "CAA":
|
|
// DO API requires that a CAA target ends in dot.
|
|
// Interestingly enough, the value returned from API doesn't
|
|
// contain a trailing dot.
|
|
target = target + "."
|
|
case "MX":
|
|
priority = int(rc.MxPreference)
|
|
case "SRV":
|
|
priority = int(rc.SrvPriority)
|
|
case "TXT":
|
|
// TXT records are the one place where DO combines many items into one field.
|
|
target = rc.GetTargetField()
|
|
default:
|
|
// no action required
|
|
}
|
|
|
|
return &godo.DomainRecordEditRequest{
|
|
Type: rc.Type,
|
|
Name: name,
|
|
Data: target,
|
|
TTL: int(rc.TTL),
|
|
Priority: priority,
|
|
Port: int(rc.SrvPort),
|
|
Weight: int(rc.SrvWeight),
|
|
Tag: rc.CaaTag,
|
|
Flags: int(rc.CaaFlag),
|
|
}
|
|
}
|
|
|
|
// backoff is the amount of time to sleep if a 429 or 504 is received.
|
|
// It is doubled after each use.
|
|
var backoff = time.Second * 5
|
|
|
|
const maxBackoff = time.Minute * 3
|
|
|
|
func pauseAndRetry(resp *godo.Response) bool {
|
|
statusCode := resp.Response.StatusCode
|
|
if statusCode != 429 && statusCode != 504 {
|
|
backoff = time.Second * 5
|
|
return false
|
|
}
|
|
|
|
// a simple exponential back-off with a 3-minute max.
|
|
log.Printf("Delaying %v due to ratelimit\n", backoff)
|
|
time.Sleep(backoff)
|
|
backoff = backoff + (backoff / 2)
|
|
if backoff > maxBackoff {
|
|
backoff = maxBackoff
|
|
}
|
|
return true
|
|
}
|