Files
jarl/internal/porkbun/porkbun.go
T
abrown38andClaude Opus 5.5 5630e7a848 Add DNSSEC signing, key backups and Porkbun support
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>
2026-10-08 21:19:04 +00:00

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
}