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