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

+117
View File
@@ -0,0 +1,117 @@
package dnssec
import (
"os"
"path/filepath"
"strings"
"testing"
)
// testdata holds key files written by named 9.20 with dnssec-policy:
// example.test just signed (default policy), fast.test turned insecure
// after its DS was removed, fast2.test turned insecure before any DS.
func TestKeys(t *testing.T) {
keys, err := Keys("testdata", "example.test.")
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 {
t.Fatalf("%d keys", len(keys))
}
k := keys[0]
if k.Tag != 22642 || k.Role != "CSK" || k.Algorithm != 13 || k.AlgorithmName != "ECDSAP256SHA256" || len(k.Files) != 3 {
t.Errorf("key: %+v", k)
}
if k.DS == nil || k.DS.KeyTag != 22642 || k.DS.DigestType != 2 || len(k.DS.Digest) != 64 || !strings.HasPrefix(k.DS.Record, "example.test.\t3600\tIN\tDS\t22642 13 2 ") {
t.Errorf("DS: %+v", k.DS)
}
if k.DNSKEYState != Rumoured || k.Goal != Omnipresent || k.PublishCDS == nil || k.PublishCDS.Format("2006-01-02 15:04") != "2026-10-09 19:08" {
t.Errorf("state: %+v", k)
}
// "fast.test." mustn't pick up fast2.test's keys, nor "test." anyone's.
if keys, _ := Keys("testdata", "fast.test."); len(keys) != 1 || keys[0].Tag != 642 {
t.Errorf("fast.test: %+v", keys)
}
if files, _ := Files("testdata", "test."); len(files) != 0 {
t.Errorf("test.: %v", files)
}
if files, _ := Files("no-such-dir", "test."); files != nil {
t.Errorf("missing dir: %v", files)
}
}
func TestAssess(t *testing.T) {
load := func(zone string) []*Key {
keys, err := Keys("testdata", zone)
if err != nil {
t.Fatal(err)
}
return keys
}
check := func(name string, p Phase, code string) {
t.Helper()
if p.Code != code {
t.Errorf("%s: %s (%s), want %s", name, p.Code, p.Summary, code)
}
}
check("unsigned", Assess("", nil), PhaseOff)
check("left over", Assess("", load("example.test.")), PhaseLeftover)
check("no keys yet", Assess("default", nil), PhaseStarting)
p := Assess("default", load("example.test."))
check("new", p, PhaseSigning)
if p.CanPublishDS || p.DSReadyAt == nil {
t.Errorf("new: %+v", p)
}
check("DS removed", Assess("insecure", load("fast.test.")), PhaseDSExpiring)
if Assess("insecure", load("fast.test.")).CanFinish {
t.Error("DNSSEC mustn't be switched off while resolvers may still have the DS cached")
}
p = Assess("insecure", load("fast2.test."))
check("no DS ever", p, PhaseRemovingKeys)
if !p.CanFinish {
t.Error("no DS: switching off is safe")
}
check("insecure, no keys", Assess("insecure", nil), PhaseUnsigned)
// The rest of the life cycle, from edited state files.
edit := func(zone string, repl ...string) []*Key {
dir := t.TempDir()
files, _ := Files("testdata", zone)
for _, f := range files {
b, _ := os.ReadFile(filepath.Join("testdata", f))
s := strings.NewReplacer(repl...).Replace(string(b))
os.WriteFile(filepath.Join(dir, f), []byte(s), 0o644)
}
keys, err := Keys(dir, zone)
if err != nil {
t.Fatal(err)
}
return keys
}
ready := edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: rumoured")
p = Assess("default", ready)
check("ready", p, PhaseReady)
if !p.CanPublishDS || len(p.NeedsDS) != 1 || p.NeedsDS[0] != 22642 {
t.Errorf("ready: %+v", p)
}
check("DS added", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSChange:", "DSPublish: 20261010000000\nDSChange:")), PhaseDSPending)
check("secure", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: omnipresent")), PhaseSecure)
p = Assess("insecure", edit("fast.test.", "DSRemoved:", "XDSRemoved:"))
check("turning off with the DS still there", p, PhaseRemovingDS)
if p.CanFinish {
t.Error("the DS is still at the parent")
}
}
func TestDerivedFiles(t *testing.T) {
dir := t.TempDir()
zf := filepath.Join(dir, "example.com.zone")
for _, f := range []string{zf, zf + ".jnl", zf + ".signed", zf + ".signed.jnl", zf + ".other"} {
os.WriteFile(f, nil, 0o644)
}
got := DerivedFiles(zf)
if len(got) != 3 {
t.Errorf("%v", got)
}
}