dnscontrol/providers/porkbun/porkbunProvider.go
2023-10-22 13:56:13 -04:00

303 lines
8.5 KiB
Go

package porkbun
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
"github.com/StackExchange/dnscontrol/v4/providers"
)
const (
minimumTTL = 600
)
// https://kb.porkbun.com/article/63-how-to-switch-to-porkbuns-nameservers
var defaultNS = []string{
"curitiba.ns.porkbun.com",
"fortaleza.ns.porkbun.com",
"maceio.ns.porkbun.com",
"salvador.ns.porkbun.com",
}
func newReg(conf map[string]string) (providers.Registrar, error) {
return newPorkbun(conf, nil)
}
func newDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
return newPorkbun(conf, metadata)
}
// newPorkbun creates the provider.
func newPorkbun(m map[string]string, metadata json.RawMessage) (*porkbunProvider, error) {
c := &porkbunProvider{}
c.apiKey, c.secretKey = m["api_key"], m["secret_key"]
if c.apiKey == "" || c.secretKey == "" {
return nil, fmt.Errorf("missing porkbun api_key or secret_key")
}
// Validate authentication
if err := c.ping(); err != nil {
return nil, err
}
return c, nil
}
var features = providers.DocumentationNotes{
providers.CanAutoDNSSEC: providers.Cannot(),
providers.CanGetZones: providers.Can(),
providers.CanUseAlias: providers.Can(),
providers.CanUseCAA: providers.Unimplemented(), // CAA record for base domain is pinning to a fixed set once configure
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.CanUseSSHFP: providers.Cannot(),
providers.CanUseTLSA: providers.Can(),
providers.DocCreateDomains: providers.Cannot(),
providers.DocDualHost: providers.Cannot(),
providers.DocOfficiallySupported: providers.Cannot(),
}
func init() {
providers.RegisterRegistrarType("PORKBUN", newReg)
fns := providers.DspFuncs{
Initializer: newDsp,
RecordAuditor: AuditRecords,
}
providers.RegisterDomainServiceProviderType("PORKBUN", fns, features)
}
// GetNameservers returns the nameservers for a domain.
func (c *porkbunProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
return models.ToNameservers(defaultNS)
}
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
func (c *porkbunProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, error) {
var corrections []*models.Correction
// Block changes to NS records for base domain
checkNSModifications(dc)
// Make sure TTL larger than the minimum TTL
for _, record := range dc.Records {
record.TTL = fixTTL(record.TTL)
}
changes, err := diff2.ByRecord(existingRecords, dc, nil)
if err != nil {
return nil, err
}
for _, change := range changes {
var corr *models.Correction
switch change.Type {
case diff2.REPORT:
corr = &models.Correction{Msg: change.MsgsJoined}
case diff2.CREATE:
req, err := toReq(change.New[0])
if err != nil {
return nil, err
}
corr = &models.Correction{
Msg: change.Msgs[0],
F: func() error {
return c.createRecord(dc.Name, req)
},
}
case diff2.CHANGE:
id := change.Old[0].Original.(*domainRecord).ID
req, err := toReq(change.New[0])
if err != nil {
return nil, err
}
corr = &models.Correction{
Msg: fmt.Sprintf("%s, porkbun ID: %s", change.Msgs[0], id),
F: func() error {
return c.modifyRecord(dc.Name, id, req)
},
}
case diff2.DELETE:
id := change.Old[0].Original.(*domainRecord).ID
corr = &models.Correction{
Msg: fmt.Sprintf("%s, porkbun ID: %s", change.Msgs[0], id),
F: func() error {
return c.deleteRecord(dc.Name, id)
},
}
default:
panic(fmt.Sprintf("unhandled change.Type %s", change.Type))
}
corrections = append(corrections, corr)
}
return corrections, nil
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (c *porkbunProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
records, err := c.getRecords(domain)
if err != nil {
return nil, err
}
existingRecords := make([]*models.RecordConfig, len(records))
for i := range records {
existingRecords[i] = toRc(domain, &records[i])
}
return existingRecords, nil
}
// parses the porkbun format into our standard RecordConfig
func toRc(domain string, r *domainRecord) *models.RecordConfig {
ttl, _ := strconv.ParseUint(r.TTL, 10, 32)
priority, _ := strconv.ParseUint(r.Prio, 10, 16)
rc := &models.RecordConfig{
Type: r.Type,
TTL: uint32(ttl),
MxPreference: uint16(priority),
SrvPriority: uint16(priority),
Original: r,
}
rc.SetLabelFromFQDN(r.Name, domain)
switch rtype := r.Type; rtype { // #rtype_variations
case "TXT":
rc.SetTargetTXT(r.Content)
case "MX", "CNAME", "ALIAS", "NS":
if strings.HasSuffix(r.Content, ".") {
rc.SetTarget(r.Content)
} else {
rc.SetTarget(r.Content + ".")
}
case "CAA":
// 0, issue, "letsencrypt.org"
c := strings.Split(r.Content, " ")
caaFlag, _ := strconv.ParseUint(c[0], 10, 8)
rc.CaaFlag = uint8(caaFlag)
rc.CaaTag = c[1]
rc.SetTarget(strings.ReplaceAll(c[2], "\"", ""))
case "TLSA":
// 0 0 0 00000000000000000000000
c := strings.Split(r.Content, " ")
tlsaUsage, _ := strconv.ParseUint(c[0], 10, 8)
rc.TlsaUsage = uint8(tlsaUsage)
tlsaSelector, _ := strconv.ParseUint(c[1], 10, 8)
rc.TlsaSelector = uint8(tlsaSelector)
tlsaMatchingType, _ := strconv.ParseUint(c[2], 10, 8)
rc.TlsaMatchingType = uint8(tlsaMatchingType)
rc.SetTarget(c[3])
case "SRV":
// 5 5060 sip.example.com
c := strings.Split(r.Content, " ")
srvWeight, _ := strconv.ParseUint(c[0], 10, 16)
rc.SrvWeight = uint16(srvWeight)
srvPort, _ := strconv.ParseUint(c[1], 10, 16)
rc.SrvPort = uint16(srvPort)
rc.SetTarget(c[2])
default:
rc.SetTarget(r.Content)
}
return rc
}
// toReq takes a RecordConfig and turns it into the native format used by the API.
func toReq(rc *models.RecordConfig) (requestParams, error) {
req := requestParams{
"type": rc.Type,
"name": rc.GetLabel(),
"content": rc.GetTargetField(),
"ttl": strconv.Itoa(int(rc.TTL)),
}
// porkbun doesn't use "@", it uses an empty name
if req["name"] == "@" {
req["name"] = ""
}
switch rc.Type { // #rtype_variations
case "A", "AAAA", "NS", "ALIAS", "CNAME":
// Nothing special.
case "TXT":
req["content"] = rc.GetTargetTXTJoined()
case "MX":
req["prio"] = strconv.Itoa(int(rc.MxPreference))
case "SRV":
req["prio"] = strconv.Itoa(int(rc.SrvPriority))
req["content"] = fmt.Sprintf("%d %d %s", rc.SrvWeight, rc.SrvPort, rc.GetTargetField())
case "CAA":
req["content"] = fmt.Sprintf("%d %s \"%s\"", rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
case "TLSA":
req["content"] = fmt.Sprintf("%d %d %d %s",
rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType, rc.GetTargetField())
default:
return nil, fmt.Errorf("porkbun.toReq rtype %q unimplemented", rc.Type)
}
return req, nil
}
func checkNSModifications(dc *models.DomainConfig) {
newList := make([]*models.RecordConfig, 0, len(dc.Records))
for _, rec := range dc.Records {
if rec.Type == "NS" && rec.GetLabelFQDN() == dc.Name {
if strings.HasSuffix(rec.GetTargetField(), ".porkbun.com") {
printer.Warnf("porkbun does not support modifying NS records on base domain. %s will not be added.\n", rec.GetTargetField())
}
continue
}
newList = append(newList, rec)
}
dc.Records = newList
}
func fixTTL(ttl uint32) uint32 {
if ttl > minimumTTL {
return ttl
}
return minimumTTL
}
func (c *porkbunProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
nss, err := c.getNameservers(dc.Name)
if err != nil {
return nil, err
}
foundNameservers := strings.Join(nss, ",")
expected := []string{}
for _, ns := range dc.Nameservers {
expected = append(expected, ns.Name)
}
sort.Strings(expected)
expectedNameservers := strings.Join(expected, ",")
if foundNameservers == expectedNameservers {
return nil, nil
}
return []*models.Correction{
{
Msg: fmt.Sprintf("Update nameservers %s -> %s", foundNameservers, expectedNameservers),
F: func() error {
return c.updateNameservers(expected, dc.Name)
},
},
}, nil
}