From d90ca157e49e4fa3ba168bff032c56159e7c572c Mon Sep 17 00:00:00 2001 From: imlonghao Date: Sun, 27 Nov 2022 00:36:17 +0800 Subject: [PATCH] PORKBUN: New provider (#1819) Co-authored-by: Tom Limoncelli --- OWNERS | 1 + README.md | 1 + docs/_includes/matrix.html | 56 ++++++ docs/_providers/porkbun.md | 41 ++++ integrationTest/providers.json | 5 + providers/_all/all.go | 1 + providers/porkbun/api.go | 123 ++++++++++++ providers/porkbun/auditrecords.go | 19 ++ providers/porkbun/porkbunProvider.go | 281 +++++++++++++++++++++++++++ 9 files changed, 528 insertions(+) create mode 100644 docs/_providers/porkbun.md create mode 100644 providers/porkbun/api.go create mode 100644 providers/porkbun/auditrecords.go create mode 100644 providers/porkbun/porkbunProvider.go diff --git a/OWNERS b/OWNERS index cf200136f..1bb0d852a 100644 --- a/OWNERS +++ b/OWNERS @@ -38,4 +38,5 @@ providers/vultr @pgaskin providers/ovh @masterzen providers/powerdns @jpbede providers/packetframe @hamptonmoore +providers/porkbun @imlonghao providers/transip @blackshadev diff --git a/README.md b/README.md index 48a295010..a42277f52 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Currently supported DNS providers: - OctoDNS - Oracle Cloud - Packetframe +- Porkbun - PowerDNS - RWTH DNS-Admin - SoftLayer diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 23c0d8b5e..c37779883 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -41,6 +41,7 @@
ORACLE
OVH
PACKETFRAME
+
PORKBUN
POWERDNS
ROUTE53
RWTH
@@ -163,6 +164,9 @@ + + + @@ -307,6 +311,9 @@ + + + Registrar @@ -421,6 +428,9 @@ + + + @@ -521,6 +531,9 @@ + + + @@ -598,6 +611,9 @@ + + + @@ -707,6 +723,9 @@ + + + @@ -820,6 +839,9 @@ + + + @@ -901,6 +923,9 @@ + + + @@ -958,6 +983,9 @@ + + + @@ -1071,6 +1099,9 @@ + + + @@ -1162,6 +1193,9 @@ + + + @@ -1266,6 +1300,9 @@ + + + @@ -1322,6 +1359,7 @@ + R53_ALIAS @@ -1366,6 +1404,7 @@ + @@ -1420,6 +1459,7 @@ + @@ -1497,6 +1537,9 @@ + + + @@ -1556,6 +1599,7 @@ + dual host @@ -1653,6 +1697,9 @@ + + + @@ -1776,6 +1823,9 @@ + + + @@ -1923,6 +1973,9 @@ + + + get-zones @@ -2035,6 +2088,9 @@ + + + diff --git a/docs/_providers/porkbun.md b/docs/_providers/porkbun.md new file mode 100644 index 000000000..9f0ca60f9 --- /dev/null +++ b/docs/_providers/porkbun.md @@ -0,0 +1,41 @@ +--- +name: Porkbun +title: Porkbun Provider +layout: default +jsId: PORKBUN +--- +# Porkbun Provider + +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `PORKBUN` +along with your `api_key` and `secret_key`. More info about authentication can be found in [Getting started with the Porkbun API](https://kb.porkbun.com/article/190-getting-started-with-the-porkbun-api). + +Example: + +```json +{ + "porkbun": { + "TYPE": "PORKBUN", + "api_key": "your-porkbun-api-key", + "secret_key": "your-porkbun-secret-key", + } +} +``` + +## Metadata + +This provider does not recognize any special metadata fields unique to Porkbun. + +## Usage + +An example `dnsconfig.js` configuration: + +```js +var REG_NONE = NewRegistrar("none"); +var DSP_PORKBUN = NewDnsProvider("porkbun"); + +D("example.tld", REG_NONE, DnsProvider(DSP_PORKBUN), + A("test", "1.2.3.4") +); +``` diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 95a8f95e3..ceb16beb1 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -188,6 +188,11 @@ "token": "$PACKETFRAME_TOKEN", "domain": "$PACKETFRAME_DOMAIN" }, + "PORKBUN": { + "api_key": "$PORKBUN_API_KEY", + "secret_key": "$PORKBUN_SECRET_KEY", + "domain": "$PORKBUN_DOMAIN" + }, "ROUTE53": { "KeyId": "$ROUTE53_KEY_ID", "SecretKey": "$ROUTE53_KEY", diff --git a/providers/_all/all.go b/providers/_all/all.go index c7d4452d7..a81002ce7 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -39,6 +39,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/oracle" _ "github.com/StackExchange/dnscontrol/v3/providers/ovh" _ "github.com/StackExchange/dnscontrol/v3/providers/packetframe" + _ "github.com/StackExchange/dnscontrol/v3/providers/porkbun" _ "github.com/StackExchange/dnscontrol/v3/providers/powerdns" _ "github.com/StackExchange/dnscontrol/v3/providers/route53" _ "github.com/StackExchange/dnscontrol/v3/providers/rwth" diff --git a/providers/porkbun/api.go b/providers/porkbun/api.go new file mode 100644 index 000000000..02d389506 --- /dev/null +++ b/providers/porkbun/api.go @@ -0,0 +1,123 @@ +package porkbun + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + baseURL = "https://porkbun.com/api/json/v3" +) + +type porkbunProvider struct { + apiKey string + secretKey string +} + +type requestParams map[string]string + +type errorResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type domainRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL string `json:"ttl"` + Prio string `json:"prio"` + Notes string `json:"notes"` +} + +type recordResponse struct { + Status string `json:"status"` + Records []domainRecord `json:"records"` +} + +func (c *porkbunProvider) post(endpoint string, params requestParams) ([]byte, error) { + params["apikey"] = c.apiKey + params["secretapikey"] = c.secretKey + + personJSON, err := json.Marshal(params) + if err != nil { + return []byte{}, err + } + + client := &http.Client{} + req, _ := http.NewRequest("POST", baseURL+endpoint, bytes.NewBuffer(personJSON)) + + // If request sending too fast, the server will fail with the following error: + // porkbun API error: Create error: We were unable to create the DNS record. + time.Sleep(300 * time.Millisecond) + resp, err := client.Do(req) + if err != nil { + return []byte{}, err + } + + bodyString, _ := io.ReadAll(resp.Body) + + // Got error from API ? + var errResp errorResponse + err = json.Unmarshal(bodyString, &errResp) + if err == nil { + if errResp.Status == "ERROR" { + return bodyString, fmt.Errorf("porkbun API error: %s URL:%s%s ", errResp.Message, req.Host, req.URL.RequestURI()) + } + } + + return bodyString, nil +} + +func (c *porkbunProvider) ping() error { + params := requestParams{} + _, err := c.post("/ping", params) + return err +} + +func (c *porkbunProvider) createRecord(domain string, rec requestParams) error { + if _, err := c.post("/dns/create/"+domain, rec); err != nil { + return fmt.Errorf("failed create record (porkbun): %s", err) + } + return nil +} + +func (c *porkbunProvider) deleteRecord(domain string, recordID string) error { + params := requestParams{} + if _, err := c.post(fmt.Sprintf("/dns/delete/%s/%s", domain, recordID), params); err != nil { + return fmt.Errorf("failed delete record (porkbun): %s", err) + } + return nil +} + +func (c *porkbunProvider) modifyRecord(domain string, recordID string, rec requestParams) error { + if _, err := c.post(fmt.Sprintf("/dns/edit/%s/%s", domain, recordID), rec); err != nil { + return fmt.Errorf("failed update (porkbun): %s", err) + } + return nil +} + +func (c *porkbunProvider) getRecords(domain string) ([]domainRecord, error) { + params := requestParams{} + var bodyString, err = c.post("/dns/retrieve/"+domain, params) + if err != nil { + return nil, fmt.Errorf("failed fetching record list from porkbun: %s", err) + } + + var dr recordResponse + json.Unmarshal(bodyString, &dr) + + var records []domainRecord + for _, rec := range dr.Records { + if rec.Name == domain && rec.Type == "NS" { + continue + } + records = append(records, rec) + } + return records, nil +} diff --git a/providers/porkbun/auditrecords.go b/providers/porkbun/auditrecords.go new file mode 100644 index 000000000..6b66bc426 --- /dev/null +++ b/providers/porkbun/auditrecords.go @@ -0,0 +1,19 @@ +package porkbun + +import ( + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/rejectif" +) + +// AuditRecords returns a list of errors corresponding to the records +// that aren't supported by this provider. If all records are +// supported, an empty list is returned. +func AuditRecords(records []*models.RecordConfig) []error { + a := rejectif.Auditor{} + + a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2022-11-19 + + a.Add("TXT", rejectif.TxtHasTrailingSpace) // Last verified 2022-11-19 + + return a.Audit(records) +} diff --git a/providers/porkbun/porkbunProvider.go b/providers/porkbun/porkbunProvider.go new file mode 100644 index 000000000..e46cc859d --- /dev/null +++ b/providers/porkbun/porkbunProvider.go @@ -0,0 +1,281 @@ +package porkbun + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/pkg/printer" + "github.com/StackExchange/dnscontrol/v3/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", +} + +// NewPorkbun creates the provider. +func NewPorkbun(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, 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.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() { + fns := providers.DspFuncs{ + Initializer: NewPorkbun, + 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) +} + +// GetDomainCorrections returns the corrections for a domain. +func (c *porkbunProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + dc, err := dc.Copy() + if err != nil { + return nil, err + } + + dc.Punycode() + + existingRecords, err := c.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + + // Block changes to NS records for base domain + checkNSModifications(dc) + + // Normalize + models.PostProcessRecords(existingRecords) + + // Make sure TTL larger than the minimum TTL + for _, record := range dc.Records { + record.TTL = fixTTL(record.TTL) + } + + differ := diff.New(dc) + _, create, del, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + + var corrections []*models.Correction + + // Deletes first so changing type works etc. + for _, m := range del { + id := m.Existing.Original.(*domainRecord).ID + corr := &models.Correction{ + Msg: fmt.Sprintf("%s, porkbun ID: %s", m.String(), id), + F: func() error { + return c.deleteRecord(dc.Name, id) + }, + } + corrections = append(corrections, corr) + } + + for _, m := range create { + req, err := toReq(m.Desired) + if err != nil { + return nil, err + } + + corr := &models.Correction{ + Msg: m.String(), + F: func() error { + return c.createRecord(dc.Name, req) + }, + } + corrections = append(corrections, corr) + } + + for _, m := range modify { + id := m.Existing.Original.(*domainRecord).ID + req, err := toReq(m.Desired) + if err != nil { + return nil, err + } + + corr := &models.Correction{ + Msg: fmt.Sprintf("%s, porkbun ID: %s: ", m.String(), id), + F: func() error { + return c.modifyRecord(dc.Name, id, req) + }, + } + 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) (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 +}