mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-09 05:36:27 +08:00
The PR follows https://github.com/StackExchange/dnscontrol/pull/3542 Found some bugs when running intergration tests locally again, and the PR is an attempt to fix them: - When updating/creating HTTPS/SRV records, Vercel API only reads from the corresponding struct (either `srv` or `https`). If we provide a `value`, the Vercel API will reject with an error. - The PR makes `Value` "nil-able", and sets `Value` to nil when dealing with `SRV` or `HTTPS` records. - When updating a record, currently, we treat the empty SVC param as omitting the field. But with Vercel's API, omitting a field means not updating the field. We need to explicitly make the field an empty string to create/update an empty SVC param, and the PR does that. - Vercel implements an unknown `ech=` parameter validation process for HTTPS records. The validation process is unknown, undocumented, thus I can't implement a `rejectif` for `AuditRecord`. - Let's make this a known caveat, describe it in the provider docs, skip these intergration tests, and move on. Please tag this PR w/ `provider-VERCEL`.
415 lines
13 KiB
Go
415 lines
13 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 = ptrString(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(),
|
|
}
|
|
// When dealing with SRV records, we must not set the Value fields,
|
|
// otherwise the API throws an error:
|
|
// bad_request - Invalid request: should NOT have additional property `value`
|
|
req.Value = nil
|
|
case "TXT":
|
|
req.Value = ptrString(rc.GetTargetTXTJoined())
|
|
case "HTTPS":
|
|
req.HTTPS = &httpsRecord{
|
|
Priority: int64(rc.SvcPriority),
|
|
Target: rc.GetTargetField(),
|
|
Params: rc.SvcParams,
|
|
}
|
|
// When dealing with HTTPS records, we must not set the Value fields,
|
|
// otherwise the API throws an error:
|
|
// bad_request - Invalid request: should NOT have additional property `value`.
|
|
req.Value = nil
|
|
case "CAA":
|
|
req.Value = ptrString(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,
|
|
}
|
|
// When dealing with SRV records, we must not set the Value fields,
|
|
// otherwise the API throws an error:
|
|
// bad_request - Invalid request: should NOT have additional property `value`
|
|
req.Value = nil
|
|
case "TXT":
|
|
txtValue := rc.GetTargetTXTJoined()
|
|
req.Value = &txtValue
|
|
case "HTTPS":
|
|
req.HTTPS = &httpsRecord{
|
|
Priority: int64(rc.SvcPriority),
|
|
Target: rc.GetTargetField(),
|
|
Params: rc.SvcParams,
|
|
}
|
|
// When dealing with HTTPS records, we must not set the Value fields,
|
|
// otherwise the API throws an error:
|
|
// bad_request - Invalid request: should NOT have additional property `value`.
|
|
req.Value = nil
|
|
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
|
|
}
|
|
|
|
func ptrString(v string) *string {
|
|
return &v
|
|
}
|