Files
jarl/internal/server/registrar_test.go
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

173 lines
7.2 KiB
Go

package server
import (
"fmt"
"net"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
)
// soaServers answers SOA queries for zone with serial on each address
// (all on one port, which it returns).
func soaServers(t *testing.T, zone string, serial uint32, addrs ...string) string {
t.Helper()
port := "0"
for _, a := range addrs {
pc, err := net.ListenPacket("udp", net.JoinHostPort(a, port))
if err != nil {
t.Skipf("can't listen on %s: %v", a, err)
}
_, port, _ = net.SplitHostPort(pc.LocalAddr().String())
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
if req.Question[0].Qtype == dns.TypeSOA {
rr, _ := dns.NewRR(zone + " 300 IN SOA ns1." + zone + " h." + zone + " " + strconv.FormatUint(uint64(serial), 10) + " 3600 900 1209600 300")
m.Answer = []dns.RR{rr}
}
w.WriteMsg(m)
})}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
}
return port
}
func TestRegistrar(t *testing.T) {
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
pts := httptest.NewServer(fake)
t.Cleanup(pts.Close)
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"},
PorkbunAllows: func(d string) bool { return d == "adbraun.lol" }})
e.do("POST", "/zones", e.token, map[string]any{
"name": "adbraun.lol", "nameservers": []string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
"records": []map[string]any{
{"name": "ns1", "type": "A", "data": "127.0.0.41"}, {"name": "ns1", "type": "AAAA", "data": "::1"},
{"name": "ns2", "type": "A", "data": "127.0.0.42"},
},
}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "other.lol"}, 201)
e.do("GET", "/zones/other.lol/registrar", e.token, nil, 400)
st := e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
if st["ns_match"] != false || len(st["glue"].([]any)) != 2 {
t.Fatalf("status: %v", st)
}
for _, g := range st["glue"].([]any) {
if g.(map[string]any)["action"] != "create" {
t.Errorf("glue: %v", g)
}
}
// Glue: a dry run changes nothing; then both hosts, IPv4 and IPv6.
out := e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, map[string]any{"dry_run": true}, 200)
if len(out["done"].([]any)) != 2 || len(fake.Domains["adbraun.lol"].Glue) != 0 {
t.Errorf("dry run: %v", out)
}
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 200)
g := fake.Domains["adbraun.lol"].Glue
sort.Strings(g["ns1.adbraun.lol"])
if strings.Join(g["ns1.adbraun.lol"], " ") != "127.0.0.41 ::1" || strings.Join(g["ns2.adbraun.lol"], " ") != "127.0.0.42" {
t.Errorf("glue at Porkbun: %v", g)
}
// NS: refused while the servers don't answer...
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(netip.Addr) bool { return false }}
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 409)
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "doesn't serve the zone") {
t.Errorf("set-ns without servers: %v", out)
}
chk := e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"dry_run": true}, 200)
if len(chk["checks"].([]any)) == 0 {
t.Errorf("dry run checks: %v", chk)
}
// ...and allowed once they serve the zone.
serial := uint32(e.do("GET", "/zones/adbraun.lol", e.token, nil, 200)["soa"].(map[string]any)["serial"].(float64))
port := soaServers(t, "adbraun.lol.", serial, "127.0.0.41", "127.0.0.42", "::1")
e.srv.nsChecker.Port = port
e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 200)
if ns := fake.Domains["adbraun.lol"].NS; strings.Join(ns, " ") != "ns1.adbraun.lol ns2.adbraun.lol" {
t.Errorf("NS at Porkbun: %v", ns)
}
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); st["ns_match"] != true {
t.Errorf("after set-ns: %v", st)
}
// A DS record for an unsigned zone: reported, removable; and setting
// NS while a foreign DS is there is refused.
fake.Domains["adbraun.lol"].DS[4242] = porkbun.DS{KeyTag: 4242, Algorithm: 13, DigestType: 2, Digest: "AB"}
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
found := false
for _, f := range st["findings"].([]any) {
if f.(map[string]any)["level"] == "error" && strings.Contains(f.(map[string]any)["message"].(string), "isn't signed") {
found = true
}
}
if !found {
t.Errorf("DS on an unsigned zone not reported: %v", st["findings"])
}
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"ns": []string{"ns1.adbraun.lol"}}, 409)
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "DS records") {
t.Errorf("set-ns with a foreign DS: %v", out)
}
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // not signed
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 200)
if len(fake.Domains["adbraun.lol"].DS) != 0 {
t.Error("DS not removed")
}
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 404)
// A signed zone whose key named has published long enough: its DS
// goes to Porkbun.
e.do("PATCH", "/zones/adbraun.lol", e.token, map[string]any{"dnssec_policy": "default"}, 200)
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // no key yet
tag := writeKey(t, filepath.Join(e.srv.eng.Config().Paths.KeysDir(), "_default"), "adbraun.lol.")
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
if d, ok := fake.Domains["adbraun.lol"].DS[tag]; !ok || d.Algorithm != 13 || d.DigestType != 2 || len(d.Digest) != 64 {
t.Errorf("DS at Porkbun: %+v", fake.Domains["adbraun.lol"].DS)
}
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
if !st["ds"].([]any)[0].(map[string]any)["matches"].(bool) {
t.Errorf("DS doesn't match: %v", st["ds"])
}
out = e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
if len(out["done"].([]any)) != 0 {
t.Errorf("published twice: %v", out)
}
// Registrar errors are passed on.
fake.Domains["adbraun.lol"].APIAccess = false
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); !strings.Contains(st["error"].(string), "API_ACCESS_DISABLED") {
t.Errorf("error: %v", st)
}
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 502)
}
// writeKey writes the .key and .state files named would have for a key
// of zone that is ready for its DS record, and returns its tag.
func writeKey(t *testing.T, dir, zone string) uint16 {
t.Helper()
k := &dns.DNSKEY{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET, Ttl: 3600}, Flags: 257, Protocol: 3, Algorithm: dns.ECDSAP256SHA256}
if _, err := k.Generate(256); err != nil {
t.Fatal(err)
}
base := filepath.Join(dir, fmt.Sprintf("K%s+013+%05d", zone, k.KeyTag()))
os.MkdirAll(dir, 0o755)
os.WriteFile(base+".key", []byte(k.String()+"\n"), 0o644)
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
return k.KeyTag()
}