Files
jarl/internal/porkbun/fake.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

165 lines
4.9 KiB
Go

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.")
}
}