mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-11-10 09:12:47 +08:00
325 lines
9.5 KiB
Go
325 lines
9.5 KiB
Go
package vultr
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/net/idna"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/StackExchange/dnscontrol/v4/models"
|
|
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
|
|
"github.com/StackExchange/dnscontrol/v4/providers"
|
|
"github.com/vultr/govultr/v2"
|
|
)
|
|
|
|
/*
|
|
|
|
Vultr API DNS provider:
|
|
|
|
Info required in `creds.json`:
|
|
- token
|
|
|
|
*/
|
|
|
|
var features = providers.DocumentationNotes{
|
|
// The default for unlisted capabilities is 'Cannot'.
|
|
// See providers/capabilities.go for the entire list of capabilities.
|
|
providers.CanGetZones: providers.Can(),
|
|
providers.CanConcur: providers.Cannot(),
|
|
providers.CanUseAlias: providers.Cannot(),
|
|
providers.CanUseCAA: providers.Can(),
|
|
providers.CanUseLOC: providers.Cannot(),
|
|
providers.CanUsePTR: providers.Cannot(),
|
|
providers.CanUseSRV: providers.Can(),
|
|
providers.CanUseSSHFP: providers.Can(),
|
|
providers.CanUseTLSA: providers.Cannot(),
|
|
providers.DocCreateDomains: providers.Can(),
|
|
providers.DocOfficiallySupported: providers.Cannot(),
|
|
}
|
|
|
|
func init() {
|
|
fns := providers.DspFuncs{
|
|
Initializer: NewProvider,
|
|
RecordAuditor: AuditRecords,
|
|
}
|
|
providers.RegisterDomainServiceProviderType("VULTR", fns, features)
|
|
}
|
|
|
|
// vultrProvider represents the Vultr DNSServiceProvider.
|
|
type vultrProvider struct {
|
|
client *govultr.Client
|
|
token string
|
|
}
|
|
|
|
// defaultNS contains the default nameservers for Vultr.
|
|
var defaultNS = []string{
|
|
"ns1.vultr.com",
|
|
"ns2.vultr.com",
|
|
}
|
|
|
|
// NewProvider initializes a Vultr DNSServiceProvider.
|
|
func NewProvider(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
|
|
token := m["token"]
|
|
if token == "" {
|
|
return nil, fmt.Errorf("missing Vultr API token")
|
|
}
|
|
|
|
config := &oauth2.Config{}
|
|
|
|
client := govultr.NewClient(config.Client(context.Background(), &oauth2.Token{AccessToken: token}))
|
|
client.SetUserAgent("dnscontrol")
|
|
|
|
_, err := client.Account.Get(context.Background())
|
|
return &vultrProvider{client, token}, err
|
|
}
|
|
|
|
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
|
|
func (api *vultrProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
|
|
listOptions := &govultr.ListOptions{}
|
|
records, recordsMeta, err := api.client.DomainRecord.List(context.Background(), domain, listOptions)
|
|
curRecords := make(models.Records, recordsMeta.Total)
|
|
nextI := 0
|
|
|
|
for {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentI := 0
|
|
for i, record := range records {
|
|
r, err := toRecordConfig(domain, record)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
curRecords[nextI+i] = r
|
|
currentI = nextI + i
|
|
}
|
|
nextI = currentI + 1
|
|
|
|
if recordsMeta.Links.Next == "" {
|
|
break
|
|
} else {
|
|
listOptions.Cursor = recordsMeta.Links.Next
|
|
records, recordsMeta, err = api.client.DomainRecord.List(context.Background(), domain, listOptions)
|
|
continue
|
|
}
|
|
}
|
|
|
|
return curRecords, nil
|
|
}
|
|
|
|
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
|
|
func (api *vultrProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, curRecords models.Records) ([]*models.Correction, error) {
|
|
var corrections []*models.Correction
|
|
|
|
for _, rec := range dc.Records {
|
|
switch rec.Type { // #rtype_variations
|
|
case "ALIAS", "MX", "NS", "CNAME", "PTR", "SRV", "URL", "URL301", "FRAME", "R53_ALIAS", "NS1_URLFWD", "AKAMAICDN", "CLOUDNS_WR":
|
|
// These rtypes are hostnames, therefore need to be converted (unlike, for example, an AAAA record)
|
|
t, err := idna.ToUnicode(rec.GetTargetField())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rec.SetTarget(t)
|
|
default:
|
|
// Nothing to do.
|
|
}
|
|
}
|
|
|
|
changes, err := diff2.ByRecord(curRecords, dc, nil)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, change := range changes {
|
|
switch change.Type {
|
|
case diff2.REPORT:
|
|
corrections = append(corrections, &models.Correction{Msg: change.MsgsJoined})
|
|
case diff2.CREATE:
|
|
r := toVultrRecord(change.New[0], "0")
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: change.Msgs[0],
|
|
F: func() error {
|
|
_, err := api.client.DomainRecord.Create(context.Background(), dc.Name, &govultr.DomainRecordReq{Name: r.Name, Type: r.Type, Data: r.Data, TTL: r.TTL, Priority: &r.Priority})
|
|
return err
|
|
},
|
|
})
|
|
case diff2.CHANGE:
|
|
r := toVultrRecord(change.New[0], change.Old[0].Original.(govultr.DomainRecord).ID)
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: fmt.Sprintf("%s; Vultr RecordID: %v", change.Msgs[0], r.ID),
|
|
F: func() error {
|
|
return api.client.DomainRecord.Update(context.Background(), dc.Name, r.ID, &govultr.DomainRecordReq{Name: r.Name, Type: r.Type, Data: r.Data, TTL: r.TTL, Priority: &r.Priority})
|
|
},
|
|
})
|
|
case diff2.DELETE:
|
|
id := change.Old[0].Original.(govultr.DomainRecord).ID
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: fmt.Sprintf("%s; Vultr RecordID: %v", change.Msgs[0], id),
|
|
F: func() error {
|
|
return api.client.DomainRecord.Delete(context.Background(), dc.Name, id)
|
|
},
|
|
})
|
|
default:
|
|
panic(fmt.Sprintf("unhandled change.Type %s", change.Type))
|
|
}
|
|
}
|
|
|
|
return corrections, nil
|
|
}
|
|
|
|
// GetNameservers gets the Vultr nameservers for a domain
|
|
func (api *vultrProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
|
|
return models.ToNameservers(defaultNS)
|
|
}
|
|
|
|
// EnsureZoneExists creates a zone if it does not exist
|
|
func (api *vultrProvider) EnsureZoneExists(domain string) error {
|
|
if ok, err := api.isDomainInAccount(domain); err != nil {
|
|
return err
|
|
} else if ok {
|
|
return nil
|
|
}
|
|
|
|
// Vultr requires an initial IP, use a dummy one.
|
|
_, err := api.client.Domain.Create(context.Background(), &govultr.DomainReq{Domain: domain, IP: "0.0.0.0", DNSSec: "disabled"})
|
|
return err
|
|
}
|
|
|
|
func (api *vultrProvider) isDomainInAccount(domain string) (bool, error) {
|
|
listOptions := &govultr.ListOptions{}
|
|
domains, meta, err := api.client.Domain.List(context.Background(), listOptions)
|
|
|
|
for {
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, d := range domains {
|
|
if d.Domain == domain {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
if meta.Links.Next == "" {
|
|
break
|
|
} else {
|
|
listOptions.Cursor = meta.Links.Next
|
|
domains, meta, err = api.client.Domain.List(context.Background(), listOptions)
|
|
continue
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// toRecordConfig converts a Vultr DomainRecord to a RecordConfig. #rtype_variations
|
|
func toRecordConfig(domain string, r govultr.DomainRecord) (*models.RecordConfig, error) {
|
|
origin, data := domain, r.Data
|
|
|
|
rc := &models.RecordConfig{
|
|
TTL: uint32(r.TTL),
|
|
Original: r,
|
|
}
|
|
rc.SetLabel(r.Name, domain)
|
|
|
|
switch rtype := r.Type; rtype {
|
|
case "ALIAS", "MX", "NS", "CNAME", "PTR", "SRV", "URL", "URL301", "FRAME", "R53_ALIAS", "NS1_URLFWD", "AKAMAICDN", "CLOUDNS_WR":
|
|
var err error
|
|
data, err = idna.ToUnicode(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
}
|
|
|
|
switch rtype := r.Type; rtype {
|
|
case "CNAME", "NS":
|
|
rc.Type = r.Type
|
|
// Make target into a FQDN if it is a CNAME, NS, MX, or SRV.
|
|
if !strings.HasSuffix(data, ".") {
|
|
data = data + "."
|
|
}
|
|
return rc, rc.SetTarget(data)
|
|
case "CAA":
|
|
// Vultr returns CAA records in the format "[flag] [tag] [value]".
|
|
return rc, rc.SetTargetCAAString(data)
|
|
case "MX":
|
|
if !strings.HasSuffix(data, ".") {
|
|
data = data + "."
|
|
}
|
|
return rc, rc.SetTargetMX(uint16(r.Priority), data)
|
|
case "SRV":
|
|
// Vultr returns SRV records in the format "[weight] [port] [target]".
|
|
if !strings.HasSuffix(data, ".") {
|
|
data = data + "."
|
|
}
|
|
return rc, rc.SetTargetSRVPriorityString(uint16(r.Priority), data)
|
|
case "TXT":
|
|
// TXT records from Vultr are always surrounded by quotes.
|
|
// They don't permit quotes within the string, therefore there is no
|
|
// need to resolve \" or other quoting.
|
|
if !(strings.HasPrefix(data, `"`) && strings.HasSuffix(data, `"`)) {
|
|
// Give an error if Vultr changes their protocol. We'd rather break
|
|
// than do the wrong thing.
|
|
return nil, errors.New("unexpected lack of quotes in TXT record from Vultr")
|
|
}
|
|
return rc, rc.SetTargetTXT(data[1 : len(data)-1])
|
|
default:
|
|
return rc, rc.PopulateFromString(rtype, r.Data, origin)
|
|
}
|
|
}
|
|
|
|
// toVultrRecord converts a RecordConfig converted by toRecordConfig back to a Vultr DomainRecordReq. #rtype_variations
|
|
func toVultrRecord(rc *models.RecordConfig, vultrID string) *govultr.DomainRecord {
|
|
name := rc.GetLabel()
|
|
// Vultr uses a blank string to represent the apex domain.
|
|
if name == "@" {
|
|
name = ""
|
|
}
|
|
|
|
data := rc.GetTargetField()
|
|
|
|
// Vultr does not use a period suffix for CNAME, NS, MX or SRV.
|
|
data = strings.TrimSuffix(data, ".")
|
|
|
|
priority := 0
|
|
|
|
if rc.Type == "MX" {
|
|
priority = int(rc.MxPreference)
|
|
}
|
|
if rc.Type == "SRV" {
|
|
priority = int(rc.SrvPriority)
|
|
}
|
|
|
|
r := &govultr.DomainRecord{
|
|
ID: vultrID,
|
|
Type: rc.Type,
|
|
Name: name,
|
|
Data: data,
|
|
TTL: int(rc.TTL),
|
|
Priority: priority,
|
|
}
|
|
switch rtype := rc.Type; rtype { // #rtype_variations
|
|
case "SRV":
|
|
if data == "" {
|
|
data = "."
|
|
}
|
|
r.Data = fmt.Sprintf("%v %v %s", rc.SrvWeight, rc.SrvPort, data)
|
|
case "CAA":
|
|
r.Data = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
|
|
case "SSHFP":
|
|
r.Data = fmt.Sprintf("%d %d %s", rc.SshfpAlgorithm, rc.SshfpFingerprint, rc.GetTargetField())
|
|
case "TXT":
|
|
// Vultr doesn't permit TXT strings to include double-quotes
|
|
// therefore, we don't have to escape interior double-quotes.
|
|
// Vultr's API requires the string to begin and end with double-quotes.
|
|
r.Data = `"` + strings.Join(rc.GetTargetTXTSegmented(), "") + `"`
|
|
default:
|
|
}
|
|
|
|
return r
|
|
}
|