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>
173 lines
7.2 KiB
Go
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()
|
|
}
|