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:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
package porkbun
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
)
// Fake is an in-memory stand-in for the Porkbun API, for tests: it knows
// one account's domains and checks the key like Porkbun does.
type Fake struct {
APIKey, Secret string
mu sync.Mutex
Domains map[string]*FakeDomain
// Calls lists the paths called, in order.
Calls []string
}
// FakeDomain is a domain in a Fake.
type FakeDomain struct {
NS []string
Glue map[string][]string // host -> addresses
DS map[uint16]DS
APIAccess bool
}
// NewFake returns a Fake with the given domains (API access on, Porkbun's
// own name servers, no glue or DS).
func NewFake(key, secret string, domains ...string) *Fake {
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
for _, d := range domains {
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
}
return f
}
func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
reply := func(status int, v map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
fail := func(status int, code, msg string) {
reply(status, map[string]any{"status": "ERROR", "code": code, "message": msg})
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
path := strings.TrimPrefix(r.URL.Path, "/api/json/v3")
f.Calls = append(f.Calls, path)
if body["apikey"] != f.APIKey || body["secretapikey"] != f.Secret {
fail(http.StatusForbidden, "INVALID_API_KEYS_001", "Invalid API key.")
return
}
if path == "/ping" {
reply(200, map[string]any{"status": "SUCCESS", "yourIp": "192.0.2.1", "credentialsValid": true, "account": "tester", "scopes": []string{"full"}})
return
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 3 {
fail(404, "NOT_FOUND", "Unknown endpoint.")
return
}
op, domain := parts[0]+"/"+parts[1], parts[2]
d := f.Domains[domain]
if d == nil {
fail(400, "DOMAIN_NOT_FOUND", "Domain not found in this account.")
return
}
if !d.APIAccess {
fail(403, "API_ACCESS_DISABLED", "The domain is not opted in to API access.")
return
}
host := ""
if len(parts) > 3 {
host = parts[3] + "." + domain
}
ips := func() []string {
var out []string
if l, ok := body["ips"].([]any); ok {
for _, v := range l {
out = append(out, v.(string))
}
}
return out
}
switch op {
case "domain/getNs":
reply(200, map[string]any{"status": "SUCCESS", "ns": d.NS})
case "domain/updateNs":
var ns []string
for _, v := range body["ns"].([]any) {
n := v.(string)
if strings.HasSuffix(n, "."+domain) && d.Glue[n] == nil {
fail(400, "INVALID_NAMESERVER", "Host "+n+" doesn't exist at the registry.")
return
}
ns = append(ns, n)
}
d.NS = ns
reply(200, map[string]any{"status": "SUCCESS"})
case "domain/getGlue":
hosts := []any{}
for h, a := range d.Glue {
v4, v6 := []string{}, []string{}
for _, ip := range a {
if strings.Contains(ip, ":") {
v6 = append(v6, ip)
} else {
v4 = append(v4, ip)
}
}
hosts = append(hosts, []any{h, map[string]any{"v4": v4, "v6": v6}})
}
reply(200, map[string]any{"status": "SUCCESS", "hosts": hosts})
case "domain/createGlue":
if d.Glue[host] != nil {
fail(400, "GLUE_EXISTS", "Host already exists.")
return
}
d.Glue[host] = ips()
reply(200, map[string]any{"status": "SUCCESS"})
case "domain/updateGlue":
if d.Glue[host] == nil {
fail(400, "GLUE_NOT_FOUND", "Host not found.")
return
}
d.Glue[host] = ips()
reply(200, map[string]any{"status": "SUCCESS"})
case "domain/deleteGlue":
for _, n := range d.NS {
if n == host {
fail(400, "GLUE_IN_USE", "Host is in use as a name server.")
return
}
}
delete(d.Glue, host)
reply(200, map[string]any{"status": "SUCCESS"})
case "dns/getDnssecRecords":
if len(d.DS) == 0 {
reply(200, map[string]any{"status": "SUCCESS", "records": []any{}})
return
}
recs := map[string]any{}
for tag, ds := range d.DS {
recs[strconv.Itoa(int(tag))] = map[string]any{"keyTag": strconv.Itoa(int(tag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": strings.ToLower(ds.Digest)}
}
reply(200, map[string]any{"status": "SUCCESS", "records": recs})
case "dns/createDnssecRecord":
num := func(k string) int { n, _ := strconv.Atoi(body[k].(string)); return n }
tag := uint16(num("keyTag"))
d.DS[tag] = DS{KeyTag: tag, Algorithm: uint8(num("alg")), DigestType: uint8(num("digestType")), Digest: strings.ToUpper(body["digest"].(string))}
reply(200, map[string]any{"status": "SUCCESS"})
case "dns/deleteDnssecRecord":
tag, _ := strconv.Atoi(parts[3])
delete(d.DS, uint16(tag))
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
default:
fail(404, "NOT_FOUND", "Unknown endpoint.")
}
}
+303
View File
@@ -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
}
+72
View File
@@ -0,0 +1,72 @@
package porkbun
import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"
)
func TestClient(t *testing.T) {
f := NewFake("pk1_x", "sk1_y", "adbraun.lol")
ts := httptest.NewServer(f)
defer ts.Close()
c := &Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_x", Secret: "sk1_y"}
ctx := context.Background()
if acct, _, err := c.Ping(ctx); err != nil || acct != "tester" {
t.Fatalf("ping: %q %v", acct, err)
}
ns, err := c.NS(ctx, "adbraun.lol.")
if err != nil || strings.Join(ns, " ") != "curitiba.ns.porkbun.com fortaleza.ns.porkbun.com" {
t.Errorf("NS: %v %v", ns, err)
}
// Two glue records, one with IPv4 and IPv6.
if err := c.SetGlue(ctx, "adbraun.lol", "ns1.adbraun.lol.", []string{"203.0.113.1", "2001:db8::1"}, false); err != nil {
t.Fatal(err)
}
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.2"}, false); err != nil {
t.Fatal(err)
}
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.3"}, true); err != nil {
t.Fatal(err)
}
if err := c.SetGlue(ctx, "adbraun.lol", "ns.elsewhere.net", nil, false); err == nil {
t.Error("glue outside the domain accepted")
}
glue, err := c.Glue(ctx, "adbraun.lol")
if err != nil || len(glue) != 2 || glue[0].Host != "ns1.adbraun.lol" || strings.Join(glue[0].Addresses(), " ") != "203.0.113.1 2001:db8::1" || glue[1].V4[0] != "203.0.113.3" {
t.Errorf("glue: %+v %v", glue, err)
}
if err := c.SetNS(ctx, "adbraun.lol", []string{"ns1.adbraun.lol.", "ns2.adbraun.lol"}); err != nil {
t.Fatal(err)
}
if err := c.DeleteGlue(ctx, "adbraun.lol", "ns2.adbraun.lol"); err == nil {
t.Error("deleted glue in use")
}
// DS records: empty comes as [], others keyed by tag.
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 0 {
t.Errorf("no DS: %v %v", ds, err)
}
if _, err := c.AddDS(ctx, "adbraun.lol", DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: "ABCDEF"}); err != nil {
t.Fatal(err)
}
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 1 || ds[0].KeyTag != 22642 || ds[0].Algorithm != 13 || ds[0].Digest != "ABCDEF" {
t.Errorf("DS: %+v %v", ds, err)
}
if err := c.DeleteDS(ctx, "adbraun.lol", 22642); err != nil {
t.Fatal(err)
}
// Errors carry Porkbun's code.
f.Domains["adbraun.lol"].APIAccess = false
_, err = c.NS(ctx, "adbraun.lol")
var pe *Error
if !errors.As(err, &pe) || pe.Code != "API_ACCESS_DISABLED" {
t.Errorf("error: %v", err)
}
bad := &Client{BaseURL: c.BaseURL, APIKey: "pk1_x", Secret: "wrong"}
if _, _, err := bad.Ping(ctx); err == nil || !strings.Contains(err.Error(), "INVALID_API_KEYS") {
t.Errorf("bad key: %v", err)
}
}