mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-09 13:46:07 +08:00
Fixes https://github.com/StackExchange/dnscontrol/issues/3379 Thanks to @SukkaW for adding this provider! Even though you claimed to be "not familiar with Go at all" the new code looks excellent! Great job!
397 lines
12 KiB
Go
397 lines
12 KiB
Go
package vercel
|
|
|
|
/*
|
|
Vercel DNS provider (vercel.com)
|
|
|
|
Info required in `creds.json`:
|
|
- team_id
|
|
- api_token
|
|
*/
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/StackExchange/dnscontrol/v4/models"
|
|
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
|
|
"github.com/StackExchange/dnscontrol/v4/providers"
|
|
"github.com/miekg/dns"
|
|
vercelClient "github.com/vercel/terraform-provider-vercel/client"
|
|
)
|
|
|
|
var features = providers.DocumentationNotes{
|
|
// The default for unlisted capabilities is 'Cannot'.
|
|
// See providers/capabilities.go for the entire list of capabilities.
|
|
providers.CanAutoDNSSEC: providers.Cannot(),
|
|
providers.CanGetZones: providers.Cannot(),
|
|
providers.CanConcur: providers.Unimplemented(),
|
|
providers.CanUseDNAME: providers.Cannot(),
|
|
providers.CanUseAlias: providers.Can(),
|
|
providers.CanUseCAA: providers.Can(),
|
|
providers.CanUseDHCID: providers.Cannot(),
|
|
providers.CanUseDS: providers.Cannot(),
|
|
providers.CanUseDSForChildren: providers.Cannot(),
|
|
providers.CanUseLOC: providers.Cannot(),
|
|
providers.CanUseNAPTR: providers.Cannot(),
|
|
providers.CanUsePTR: providers.Cannot(),
|
|
providers.CanUseSOA: providers.Cannot(),
|
|
providers.CanUseSRV: providers.Can(),
|
|
providers.CanUseSVCB: providers.Cannot(),
|
|
providers.CanUseHTTPS: providers.Can(),
|
|
providers.CanUseSSHFP: providers.Cannot(),
|
|
providers.CanUseTLSA: providers.Cannot(),
|
|
providers.CanUseDNSKEY: providers.Cannot(),
|
|
providers.DocCreateDomains: providers.Cannot("Vercel requires a domain to be associated with a project before it can be added and managed"),
|
|
providers.DocDualHost: providers.Cannot("Vercel does not allow sufficient control over the apex NS records"),
|
|
providers.DocOfficiallySupported: providers.Cannot(),
|
|
}
|
|
|
|
// vercelProvider stores login credentials and represents and API connection
|
|
type vercelProvider struct {
|
|
client vercelClient.Client
|
|
apiToken string
|
|
teamID string
|
|
|
|
createLimiter *rateLimiter
|
|
updateLimiter *rateLimiter
|
|
deleteLimiter *rateLimiter
|
|
listLimiter *rateLimiter
|
|
}
|
|
|
|
// uint16Zero converts value to uint16 or returns 0, use wisely
|
|
//
|
|
// Vercel's Go SDK implies int64 for almost everything, but since Vercel doesn't actually
|
|
// implement their own NS and instead uses NS1 / Constellix (previously), we'd assume if
|
|
// TTL and Priority are int64, they are in fact uint16 and otherwise be rejected by upstream
|
|
// providers. Under this assumption, we'd convert int64 to uint16 as wells.
|
|
func uint16Zero(value interface{}) uint16 {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return uint16(v)
|
|
case uint16:
|
|
return v
|
|
case int64:
|
|
return uint16(v)
|
|
case nil:
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func init() {
|
|
const providerName = "VERCEL"
|
|
const providerMaintainer = "@SukkaW"
|
|
fns := providers.DspFuncs{
|
|
Initializer: newProvider,
|
|
RecordAuditor: AuditRecords,
|
|
}
|
|
providers.RegisterDomainServiceProviderType(providerName, fns, providers.CanUseSRV, features)
|
|
providers.RegisterMaintainer(providerName, providerMaintainer)
|
|
}
|
|
|
|
func newProvider(creds map[string]string, meta json.RawMessage) (providers.DNSServiceProvider, error) {
|
|
if creds["api_token"] == "" {
|
|
return nil, errors.New("api_token required for VERCEL")
|
|
}
|
|
|
|
c := vercelClient.New(
|
|
creds["api_token"],
|
|
)
|
|
|
|
ctx := context.Background()
|
|
|
|
team, err := c.Team(ctx, creds["team_id"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c = c.WithTeam(team)
|
|
return &vercelProvider{
|
|
client: *c,
|
|
apiToken: creds["api_token"],
|
|
teamID: creds["team_id"],
|
|
// rate limiters
|
|
createLimiter: newRateLimiter(100, time.Hour),
|
|
updateLimiter: newRateLimiter(50, time.Minute),
|
|
deleteLimiter: newRateLimiter(50, time.Minute),
|
|
listLimiter: newRateLimiter(50, time.Minute),
|
|
}, nil
|
|
}
|
|
|
|
// GetNameservers returns empty array.
|
|
// Vercel doesn't permit apex NS records. Vercel's API doesn't even include apex NS records in their API response
|
|
// To prevent DNSControl from trying to create default NS records, let' return an empty array here, just like
|
|
// exoscale provider and gandi v5 provider
|
|
func (c *vercelProvider) GetNameservers(_ string) ([]*models.Nameserver, error) {
|
|
return []*models.Nameserver{}, nil
|
|
}
|
|
|
|
func (c *vercelProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
|
|
var zoneRecords []*models.RecordConfig
|
|
|
|
records, err := c.ListDNSRecords(context.Background(), domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, r := range records {
|
|
// Vercel has some system-created records that can't be deleted/modified. They can be overridden
|
|
// by creating new records (where the DNS will prefer your record), but those system records are
|
|
// still included in the API response.
|
|
//
|
|
// Those records will have their "creator" being "system", some of them even has a comment field
|
|
// "Vercel automatically manages this record. It may change without notice".
|
|
//
|
|
// Per https://github.com/StackExchange/dnscontrol/pull/3542#issuecomment-3560041419, let's
|
|
// pretend those records don't exist, and diff2.ByRecord() will not affect these existing records.
|
|
if r.Creator == "system" {
|
|
continue
|
|
}
|
|
|
|
rc := &models.RecordConfig{
|
|
TTL: uint32(r.TTL),
|
|
Original: r,
|
|
}
|
|
|
|
name := r.Name
|
|
if name == "@" {
|
|
name = ""
|
|
}
|
|
rc.SetLabel(name, domain)
|
|
|
|
if r.Type == "CNAME" || r.Type == "MX" {
|
|
r.Value = dns.CanonicalName(r.Value)
|
|
}
|
|
|
|
switch rtype := r.RecordType; rtype {
|
|
case "MX":
|
|
if err := rc.SetTargetMX(uint16Zero(r.MXPriority), r.Value); err != nil {
|
|
return nil, fmt.Errorf("unparsable MX record: %w", err)
|
|
}
|
|
case "SRV":
|
|
// Vercel's API doesn't always return SRV as an SRV object.
|
|
// It might return priority in the json field, and the srv as a big string `[weight] [port] [domain]` in json 'value' field.
|
|
// We have to create our own string before passing in.
|
|
// Fallback to parsing from string if SRV object is missing
|
|
// r.Value is "weight port target", we need "priority weight port target"
|
|
if err := rc.PopulateFromString(
|
|
rtype,
|
|
fmt.Sprintf("%d %s", uint16Zero(r.Priority), r.Value),
|
|
domain,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("unparsable SRV record from value: %w", err)
|
|
}
|
|
case "HTTPS":
|
|
// Vercel returns priority in a separate field, and value contains "target params".
|
|
// We need to combine them for PopulateFromString.
|
|
if err := rc.PopulateFromString(
|
|
rtype,
|
|
fmt.Sprintf("%d %s", uint16Zero(r.Priority), r.Value),
|
|
domain,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("unparsable HTTPS record: %w", err)
|
|
}
|
|
case "TXT":
|
|
err := rc.SetTargetTXT(r.Value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unparsable TXT record: %w", err)
|
|
}
|
|
default:
|
|
if err := rc.PopulateFromString(rtype, r.Value, domain); err != nil {
|
|
return nil, fmt.Errorf("unparsable record received from vercel: %w", err)
|
|
}
|
|
}
|
|
|
|
zoneRecords = append(zoneRecords, rc)
|
|
}
|
|
|
|
return zoneRecords, nil
|
|
}
|
|
|
|
func (c *vercelProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, records models.Records) ([]*models.Correction, int, error) {
|
|
// Vercel is a "ByRecord" API.
|
|
|
|
// Vercel enforces a minimum TTL of 60 seconds
|
|
for _, record := range dc.Records {
|
|
record.TTL = max(record.TTL, 60)
|
|
}
|
|
|
|
instructions, actualChangeCount, err := diff2.ByRecord(records, dc, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var corrections []*models.Correction
|
|
for _, inst := range instructions {
|
|
switch inst.Type {
|
|
case diff2.REPORT:
|
|
corrections = append(corrections, &models.Correction{
|
|
Msg: inst.MsgsJoined,
|
|
})
|
|
case diff2.CREATE:
|
|
corrections = append(corrections, c.mkCreateCorrection(dc.Name, inst.New[0], inst.Msgs[0]))
|
|
case diff2.CHANGE:
|
|
corrections = append(corrections, c.mkChangeCorrection(dc.Name, inst.Old[0], inst.New[0], inst.Msgs[0]))
|
|
case diff2.DELETE:
|
|
corrections = append(corrections, c.mkDeleteCorrection(dc.Name, inst.Old[0], inst.Msgs[0]))
|
|
default:
|
|
panic(fmt.Sprintf("unhandled inst.Type %s", inst.Type))
|
|
}
|
|
}
|
|
|
|
return corrections, actualChangeCount, nil
|
|
}
|
|
|
|
func (c *vercelProvider) mkCreateCorrection(domain string, newRec *models.RecordConfig, msg string) *models.Correction {
|
|
return &models.Correction{
|
|
Msg: msg,
|
|
F: func() error {
|
|
ctx := context.Background()
|
|
req, err := toVercelCreateRequest(domain, newRec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.CreateDNSRecord(ctx, req)
|
|
return err
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *vercelProvider) mkChangeCorrection(domain string, oldRec, newRec *models.RecordConfig, msg string) *models.Correction {
|
|
return &models.Correction{
|
|
Msg: msg,
|
|
F: func() error {
|
|
ctx := context.Background()
|
|
existingID := oldRec.Original.(DNSRecord).ID
|
|
|
|
// UpdateDNSRecord doesn't support type changes
|
|
// If record type changed, delete and re-create
|
|
if oldRec.Type != newRec.Type {
|
|
// Delete old record
|
|
if err := c.DeleteDNSRecord(ctx, domain, existingID); err != nil {
|
|
return err
|
|
}
|
|
// re-create new record.
|
|
// luckily, delete and create use different rate limit timers
|
|
// thus we are most likely can go through both.
|
|
req, err := toVercelCreateRequest(domain, newRec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.CreateDNSRecord(ctx, req)
|
|
return err
|
|
}
|
|
|
|
req, err := toVercelUpdateRequest(newRec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.UpdateDNSRecord(ctx, existingID, req)
|
|
return err
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *vercelProvider) mkDeleteCorrection(domain string, oldRec *models.RecordConfig, msg string) *models.Correction {
|
|
return &models.Correction{
|
|
Msg: msg,
|
|
F: func() error {
|
|
ctx := context.Background()
|
|
existingID := oldRec.Original.(DNSRecord).ID
|
|
return c.DeleteDNSRecord(ctx, domain, existingID)
|
|
},
|
|
}
|
|
}
|
|
|
|
// toVercelCreateRequest converts a RecordConfig to a Vercel CreateDNSRecordRequest.
|
|
func toVercelCreateRequest(domain string, rc *models.RecordConfig) (createDNSRecordRequest, error) {
|
|
req := createDNSRecordRequest{}
|
|
|
|
req.Domain = domain
|
|
|
|
name := rc.GetLabel()
|
|
if name == "@" {
|
|
name = ""
|
|
}
|
|
req.Name = name
|
|
req.Type = rc.Type
|
|
req.Value = rc.GetTargetField()
|
|
req.TTL = int64(rc.TTL)
|
|
req.Comment = ""
|
|
|
|
switch rc.Type {
|
|
case "MX":
|
|
req.MXPriority = int64(rc.MxPreference)
|
|
case "SRV":
|
|
req.SRV = &vercelClient.SRV{
|
|
Priority: int64(rc.SrvPriority),
|
|
Weight: int64(rc.SrvWeight),
|
|
Port: int64(rc.SrvPort),
|
|
Target: rc.GetTargetField(),
|
|
}
|
|
req.Value = "" // SRV uses the SRV struct, not Value
|
|
case "TXT":
|
|
req.Value = rc.GetTargetTXTJoined()
|
|
case "HTTPS":
|
|
req.HTTPS = &httpsRecord{
|
|
Priority: int64(rc.SvcPriority),
|
|
Target: rc.GetTargetField(),
|
|
Params: rc.SvcParams,
|
|
}
|
|
case "CAA":
|
|
req.Value = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// toVercelUpdateRequest converts a RecordConfig to a Vercel UpdateDNSRecordRequest.
|
|
func toVercelUpdateRequest(rc *models.RecordConfig) (updateDNSRecordRequest, error) {
|
|
req := updateDNSRecordRequest{}
|
|
|
|
name := rc.GetLabel()
|
|
if name == "@" {
|
|
name = ""
|
|
}
|
|
req.Name = &name
|
|
|
|
value := rc.GetTargetField()
|
|
req.Value = &value
|
|
|
|
req.TTL = ptrInt64(int64(rc.TTL))
|
|
req.Comment = ""
|
|
|
|
switch rc.Type {
|
|
case "MX":
|
|
req.MXPriority = ptrInt64(int64(rc.MxPreference))
|
|
case "SRV":
|
|
req.SRV = &vercelClient.SRVUpdate{
|
|
Priority: ptrInt64(int64(rc.SrvPriority)),
|
|
Weight: ptrInt64(int64(rc.SrvWeight)),
|
|
Port: ptrInt64(int64(rc.SrvPort)),
|
|
Target: &value,
|
|
}
|
|
req.Value = nil // SRV uses the SRV struct, not Value
|
|
case "TXT":
|
|
txtValue := rc.GetTargetTXTJoined()
|
|
req.Value = &txtValue
|
|
case "HTTPS":
|
|
req.HTTPS = &httpsRecord{
|
|
Priority: int64(rc.SvcPriority),
|
|
Target: rc.GetTargetField(),
|
|
Params: rc.SvcParams,
|
|
}
|
|
case "CAA":
|
|
value := fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
|
|
req.Value = &value
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// ptrInt64 returns a pointer to an int64
|
|
func ptrInt64(v int64) *int64 {
|
|
return &v
|
|
}
|