Zones get a DNSSEC setting: named signs them with BIND's default (or a custom) dnssec-policy and inline signing, with keys in a per-view key directory. Jarl shows the keys, the DS record and when it is safe to publish it, checks the parent's DS records (check-ds, which also tells named), and refuses steps that would break a signed domain: switching straight to unsigned, finishing a turn-off too early, deleting or moving a signed zone, TTLs above max-zone-ttl. Turning off goes through the insecure policy; wipe starts over once the parent has no DS. Backups store the DNSSEC keys and restore puts them back for named; the backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's serial above named's whenever it writes an older one (after a restore). The serials check compares the signed serial. With a Porkbun API key in jarl.yaml, the registrar's name servers, glue records and DS records can be shown and changed, each change checked first (dry run, force to override). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
9.0 KiB
Go
304 lines
9.0 KiB
Go
// 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.
|
|
//
|
|
// 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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|