// Package porkbun is a small client for the parts of Porkbun's API v3 // (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage // a domain's delegation: its name servers, glue records (host objects at // the registry) and DNSSEC DS records, and the TXT records of DNS-01 // challenges while Porkbun still serves a domain (docs/certificates.md). // // Every call is a POST with the API key and secret in the JSON body. API // access must be switched on for each domain in Porkbun's web interface, // and a key can be limited to some domains, permissions and source // addresses there. package porkbun import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" ) // DefaultURL is the API's base URL. const DefaultURL = "https://api.porkbun.com/api/json/v3" // Client calls the Porkbun API. type Client struct { BaseURL string APIKey string Secret string HTTP *http.Client } // Error is an error the API returned. type Error struct { Status int // HTTP status Code string // e.g. API_ACCESS_DISABLED Message string } func (e *Error) Error() string { msg := "Porkbun: " + e.Message if e.Code != "" { msg += " (" + e.Code + ")" } return msg } // Glue is a glue record (a host object at the registry). type Glue struct { Host string `json:"host"` // full host name, without a trailing dot V4 []string `json:"v4"` V6 []string `json:"v6"` } // Addresses returns all of the glue's addresses. func (g Glue) Addresses() []string { return append(append([]string{}, g.V4...), g.V6...) } // DS is a DS record at the registry. type DS struct { KeyTag uint16 `json:"key_tag"` Algorithm uint8 `json:"algorithm"` DigestType uint8 `json:"digest_type"` Digest string `json:"digest"` } type response struct { Status string `json:"status"` Message string `json:"message"` Code string `json:"code"` Warnings []string `json:"warnings"` NS []string `json:"ns"` Hosts json.RawMessage `json:"hosts"` Records json.RawMessage `json:"records"` YourIP string `json:"yourIp"` Valid bool `json:"credentialsValid"` Account string `json:"account"` Scopes []string `json:"scopes"` ID json.Number `json:"id"` } func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) { if body == nil { body = map[string]any{} } body["apikey"], body["secretapikey"] = c.APIKey, c.Secret b, err := json.Marshal(body) if err != nil { return nil, err } base := c.BaseURL if base == "" { base = DefaultURL } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(base, "/")+path, bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "jarl") hc := c.HTTP if hc == nil { hc = &http.Client{Timeout: 30 * time.Second} } resp, err := hc.Do(req) if err != nil { return nil, fmt.Errorf("Porkbun: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, fmt.Errorf("Porkbun: %w", err) } var r response if err := json.Unmarshal(raw, &r); err != nil { return nil, &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected answer (HTTP %d)", resp.StatusCode)} } if r.Status != "SUCCESS" { msg := r.Message if msg == "" { msg = fmt.Sprintf("request failed (HTTP %d)", resp.StatusCode) } return &r, &Error{Status: resp.StatusCode, Code: r.Code, Message: msg} } return &r, nil } func domainPath(op, domain string, rest ...string) string { p := op + "/" + url.PathEscape(strings.ToLower(strings.TrimSuffix(domain, "."))) for _, r := range rest { p += "/" + url.PathEscape(r) } return p } // Ping checks the credentials and returns the account name and the key's // permission scopes. func (c *Client) Ping(ctx context.Context) (account string, scopes []string, err error) { r, err := c.call(ctx, "/ping", nil) if err != nil { return "", nil, err } if !r.Valid { return "", nil, &Error{Code: "INVALID_API_KEYS_001", Message: "the API key wasn't accepted"} } return r.Account, r.Scopes, nil } // NS returns the domain's name servers at the registry (lowercase, // without trailing dots, sorted). func (c *Client) NS(ctx context.Context, domain string) ([]string, error) { r, err := c.call(ctx, domainPath("/domain/getNs", domain), nil) if err != nil { return nil, err } out := []string{} for _, n := range r.NS { out = append(out, normName(n)) } sort.Strings(out) return out, nil } // SetNS replaces the domain's name servers at the registry. func (c *Client) SetNS(ctx context.Context, domain string, ns []string) error { list := make([]string, len(ns)) for i, n := range ns { list[i] = normName(n) } _, err := c.call(ctx, domainPath("/domain/updateNs", domain), map[string]any{"ns": list}) return err } // Glue returns the domain's glue records. func (c *Client) Glue(ctx context.Context, domain string) ([]Glue, error) { r, err := c.call(ctx, domainPath("/domain/getGlue", domain), nil) if err != nil { return nil, err } out := []Glue{} if len(r.Hosts) == 0 || string(r.Hosts) == "null" { return out, nil } var tuples [][]json.RawMessage if err := json.Unmarshal(r.Hosts, &tuples); err != nil { return nil, fmt.Errorf("Porkbun: unexpected glue records: %w", err) } for _, t := range tuples { if len(t) != 2 { continue } var g Glue var ips struct{ V4, V6 []string } if json.Unmarshal(t[0], &g.Host) != nil || json.Unmarshal(t[1], &ips) != nil { continue } g.Host, g.V4, g.V6 = normName(g.Host), nonNil(ips.V4), nonNil(ips.V6) out = append(out, g) } sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) return out, nil } // SetGlue creates the glue record for host (a name inside domain) or, when // exists, replaces its addresses. func (c *Client) SetGlue(ctx context.Context, domain, host string, ips []string, exists bool) error { sub, err := subdomain(domain, host) if err != nil { return err } op := "/domain/createGlue" if exists { op = "/domain/updateGlue" } _, err = c.call(ctx, domainPath(op, domain, sub), map[string]any{"ips": ips}) return err } // DeleteGlue deletes the glue record for host. The registry refuses while // the domain (or another one) uses it as a name server. func (c *Client) DeleteGlue(ctx context.Context, domain, host string) error { sub, err := subdomain(domain, host) if err != nil { return err } _, err = c.call(ctx, domainPath("/domain/deleteGlue", domain, sub), nil) return err } // DS returns the domain's DS records at the registry. func (c *Client) DS(ctx context.Context, domain string) ([]DS, error) { r, err := c.call(ctx, domainPath("/dns/getDnssecRecords", domain), nil) if err != nil { return nil, err } out := []DS{} type rec struct { KeyTag json.Number `json:"keyTag"` Alg json.Number `json:"alg"` DigestType json.Number `json:"digestType"` Digest string `json:"digest"` } var recs []rec // An object keyed by key tag; an empty one may come as []. var byTag map[string]rec if err := json.Unmarshal(r.Records, &byTag); err == nil { for _, v := range byTag { recs = append(recs, v) } } else if err := json.Unmarshal(r.Records, &recs); err != nil && len(r.Records) > 0 && string(r.Records) != "null" { return nil, fmt.Errorf("Porkbun: unexpected DNSSEC records: %w", err) } for _, v := range recs { tag, _ := strconv.ParseUint(v.KeyTag.String(), 10, 16) alg, _ := strconv.ParseUint(v.Alg.String(), 10, 8) dt, _ := strconv.ParseUint(v.DigestType.String(), 10, 8) out = append(out, DS{KeyTag: uint16(tag), Algorithm: uint8(alg), DigestType: uint8(dt), Digest: strings.ToUpper(v.Digest)}) } sort.Slice(out, func(i, j int) bool { return out[i].KeyTag < out[j].KeyTag }) return out, nil } // AddDS adds a DS record at the registry. Warnings (a value the registry is // retiring) come back with a nil error. func (c *Client) AddDS(ctx context.Context, domain string, ds DS) ([]string, error) { r, err := c.call(ctx, domainPath("/dns/createDnssecRecord", domain), map[string]any{ "keyTag": strconv.Itoa(int(ds.KeyTag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": ds.Digest, }) if err != nil { return nil, err } return r.Warnings, nil } // DeleteDS deletes the DS record with key tag at the registry. Porkbun // warns that most registries delete every record with the same key data. func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) error { _, err := c.call(ctx, domainPath("/dns/deleteDnssecRecord", domain, strconv.Itoa(int(keyTag))), nil) return err } // MinTTL is the lowest TTL Porkbun accepts for a DNS record. const MinTTL = 600 // CreateTXT adds a TXT record at name (a name inside domain, or the // domain itself) in Porkbun's DNS and returns its ID. func (c *Client) CreateTXT(ctx context.Context, domain, name, value string) (string, error) { sub := "" if normName(name) != normName(domain) { var err error if sub, err = subdomain(domain, name); err != nil { return "", err } } r, err := c.call(ctx, domainPath("/dns/create", domain), map[string]any{ "name": sub, "type": "TXT", "content": value, "ttl": strconv.Itoa(MinTTL), }) if err != nil { return "", err } if r.ID == "" { return "", &Error{Message: "the record was created but Porkbun didn't say its ID"} } return r.ID.String(), nil } // DeleteRecord deletes the DNS record with id. func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error { _, err := c.call(ctx, domainPath("/dns/delete", domain, id), nil) return err } // subdomain returns host's label(s) below domain ("ns1" for // ns1.example.com in example.com). func subdomain(domain, host string) (string, error) { d, h := normName(domain), normName(host) sub, ok := strings.CutSuffix(h, "."+d) if !ok || sub == "" { return "", fmt.Errorf("%s isn't inside %s, so it can't have a glue record there", h, d) } return sub, nil } func normName(n string) string { return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(n), ".")) } func nonNil(s []string) []string { if s == nil { return []string{} } return s }