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:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
package server
import (
"strings"
"testing"
"time"
"jarl/internal/dnssec"
"jarl/internal/model"
"jarl/internal/nscheck"
)
func TestDNSSECEndpoints(t *testing.T) {
e := newAPIEnv(t)
// Nothing answers DNS here, so the parent can't be checked.
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Resolver: "127.0.0.1:1"}
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
e.do("GET", "/zones/example.com/dnssec?view=", e.token, nil, 200)
e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "default"}, 200)
got := e.do("GET", "/zones/example.com/dnssec", e.token, nil, 200)
if got["policy"] != "default" || got["phase"].(map[string]any)["code"] != dnssec.PhaseStarting || !strings.HasSuffix(got["key_dir"].(string), "zones/dnssec-keys/_default") {
t.Errorf("status: %v", got)
}
// Switching off at once is refused; insecure is the way.
out := e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "off"}, 422)
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "switching DNSSEC off at once") {
t.Errorf("switching off: %v", out)
}
cd := e.do("POST", "/zones/example.com/dnssec/check-ds", e.token, nil, 200)
if cd["parent"].(map[string]any)["error"] == "" || len(cd["marked"].([]any)) != 0 {
t.Errorf("check-ds without a resolver: %v", cd)
}
// Wipe: refused while the parent can't be checked, unless forced.
out = e.do("POST", "/zones/example.com/dnssec/wipe", e.token, nil, 409)
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "wipe with force") {
t.Errorf("wipe: %v", out)
}
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true, "dry_run": true}, 200)
if e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"] != "default" {
t.Error("a dry run changed the zone")
}
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 200)
if p, ok := e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"]; ok && p != "" {
t.Errorf("after wipe: %v", p)
}
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 409) // nothing left
// Secondary zones aren't signed by Jarl.
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ 192.0.2.1; }"}}}, 201)
e.do("GET", "/zones/copy.example/dnssec", e.token, nil, 400)
e.do("PATCH", "/zones/copy.example", e.token, map[string]any{"dnssec_policy": "default"}, 422)
}
func TestDSChanges(t *testing.T) {
ds := dnssec.DS{KeyTag: 1, Algorithm: 13, DigestType: 2, Digest: "AA"}
key := func(goal, dsState string) *dnssec.Key {
d := ds
return &dnssec.Key{Tag: 1, Role: "CSK", DS: &d, Goal: goal, DSState: dsState}
}
with := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated", DS: []dnssec.DS{ds}}}, DS: []dnssec.DS{ds}, Consistent: true}
half := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated"}}, DS: []dnssec.DS{ds}}
without := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated"}}, Consistent: true}
signed := &model.Zone{DNSSECPolicy: "default"}
off := &model.Zone{DNSSECPolicy: "insecure"}
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, with); len(p) != 1 || len(w) != 0 {
t.Errorf("DS added: %v %v", p, w)
}
if p, _ := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, half); len(p) != 0 {
t.Error("published while not every parent server has it")
}
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, without); len(p)+len(w) != 0 {
t.Error("a signed zone without a DS yet is just waiting")
}
if p, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, without); len(p) != 0 || len(w) != 1 {
t.Errorf("DS removed: %v %v", p, w)
}
if _, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, half); len(w) != 0 {
t.Error("withdrawn while a parent server still has it")
}
}