package porkbun import ( "encoding/json" "errors" "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" "github.com/miekg/dns/dnsutil" ) const ( minimumTTL = 600 ) const ( metaType = "type" metaIncludePath = "includePath" metaWildcard = "wildcard" ) // 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, _ json.RawMessage) (*porkbunProvider, error) { c := &porkbunProvider{} c.apiKey, c.secretKey = m["api_key"], m["secret_key"] if c.apiKey == "" || c.secretKey == "" { return nil, errors.New("missing porkbun api_key or secret_key") } return c, nil } 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.Can(), providers.CanConcur: providers.Unimplemented(), providers.CanUseAlias: providers.Can(), providers.CanUseCAA: providers.Can(), 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.CanUseHTTPS: providers.Can(), providers.CanUseSVCB: providers.Can(), providers.DocCreateDomains: providers.Cannot(), providers.DocDualHost: providers.Cannot(), providers.DocOfficiallySupported: providers.Cannot(), } func init() { const providerName = "PORKBUN" const providerMaintainer = "@imlonghao" providers.RegisterRegistrarType(providerName, newReg) fns := providers.DspFuncs{ Initializer: newDsp, RecordAuditor: AuditRecords, } providers.RegisterDomainServiceProviderType(providerName, fns, features) providers.RegisterMaintainer(providerName, providerMaintainer) providers.RegisterCustomRecordType("PORKBUN_URLFWD", providerName, "") } // GetNameservers returns the nameservers for a domain. func (c *porkbunProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { return models.ToNameservers(defaultNS) } func genComparable(rec *models.RecordConfig) string { if rec.Type == "PORKBUN_URLFWD" { return fmt.Sprintf("type=%s includePath=%s wildcard=%s", rec.Metadata[metaType], rec.Metadata[metaIncludePath], rec.Metadata[metaWildcard]) } return "" } // 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, int, 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) if record.Type == "PORKBUN_URLFWD" { record.TTL = 0 if record.Metadata == nil { record.Metadata = make(map[string]string) } if record.Metadata[metaType] == "" { record.Metadata[metaType] = "temporary" } if record.Metadata[metaIncludePath] == "" { record.Metadata[metaIncludePath] = "no" } if record.Metadata[metaWildcard] == "" { record.Metadata[metaWildcard] = "yes" } } } changes, actualChangeCount, err := diff2.ByRecord(existingRecords, dc, genComparable) if err != nil { return nil, 0, 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, 0, err } corr = &models.Correction{ Msg: change.Msgs[0], F: func() error { if change.New[0].Type == "PORKBUN_URLFWD" { return c.createURLForwardingRecord(dc.Name, req) } 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, 0, err } corr = &models.Correction{ Msg: fmt.Sprintf("%s, porkbun ID: %s", change.Msgs[0], id), F: func() error { if change.New[0].Type == "PORKBUN_URLFWD" { return c.modifyURLForwardingRecord(dc.Name, id, req) } 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 { if change.Old[0].Type == "PORKBUN_URLFWD" { return c.deleteURLForwardingRecord(dc.Name, id) } return c.deleteRecord(dc.Name, id) }, } default: panic(fmt.Sprintf("unhandled change.Type %s", change.Type)) } corrections = append(corrections, corr) } return corrections, actualChangeCount, 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 } forwards, err := c.getURLForwardingRecords(domain) if err != nil { return nil, err } existingRecords := make([]*models.RecordConfig, 0) for i := range records { shouldSkip := false if strings.HasSuffix(records[i].Content, ".porkbun.com") { name := dnsutil.TrimDomainName(records[i].Name, domain) if name == "@" { name = "" } if records[i].Type == "ALIAS" { for _, forward := range forwards { if name == forward.Subdomain { shouldSkip = true break } } } if records[i].Type == "CNAME" { for _, forward := range forwards { if name == "*."+forward.Subdomain { shouldSkip = true break } } } } if shouldSkip { continue } newr, err := toRc(domain, &records[i]) if err != nil { return nil, err } existingRecords = append(existingRecords, newr) } for i := range forwards { r := &forwards[i] rc := &models.RecordConfig{ Type: "PORKBUN_URLFWD", Original: r, Metadata: map[string]string{ metaType: r.Type, metaIncludePath: r.IncludePath, metaWildcard: r.Wildcard, }, } rc.SetLabel(r.Subdomain, domain) if err := rc.SetTarget(r.Location); err != nil { return nil, err } existingRecords = append(existingRecords, rc) } return existingRecords, nil } // parses the porkbun format into our standard RecordConfig func toRc(domain string, r *domainRecord) (*models.RecordConfig, error) { 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) var err error switch rtype := r.Type; rtype { // #rtype_variations case "TXT": err = rc.SetTargetTXT(r.Content) case "MX", "CNAME", "ALIAS", "NS": if strings.HasSuffix(r.Content, ".") { err = rc.SetTarget(r.Content) } else { err = 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] err = 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) err = 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) err = rc.SetTarget(c[2]) case "HTTPS": fallthrough case "SVCB": // 5 . ech=AAAAABBBBB... c := strings.Split(r.Content, " ") svcPriority, _ := strconv.ParseUint(c[0], 10, 16) rc.SvcPriority = uint16(svcPriority) rc.SvcParams = c[2] err = rc.SetTarget(c[1]) default: err = rc.SetTarget(r.Content) } return rc, err } // toReq takes a RecordConfig and turns it into the native format used by the API. func toReq(rc *models.RecordConfig) (requestParams, error) { if rc.Type == "PORKBUN_URLFWD" { subdomain := rc.GetLabel() if subdomain == "@" { subdomain = "" } return requestParams{ "subdomain": subdomain, "location": rc.GetTargetField(), "type": rc.Metadata[metaType], "includePath": rc.Metadata[metaIncludePath], "wildcard": rc.Metadata[metaWildcard], }, nil } 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()) case "HTTPS": fallthrough case "SVCB": req["content"] = fmt.Sprintf("%d %s %s", rc.SvcPriority, rc.GetTargetField(), rc.SvcParams) 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 }