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>
This commit is contained in:
1 parent
293e6ddd33
commit
5630e7a848
72 files changed
+5760
-153
No files matched your search
@@ -0,0 +1,303 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user