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,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.")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user