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>
118 lines
4.2 KiB
Go
118 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|