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

+148
View File
@@ -0,0 +1,148 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"jarl/internal/dnssec"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/store"
)
// waitPhase polls the zone's DNSSEC status until ok accepts it.
func (n *namedEnv) waitPhase(z *model.Zone, what string, ok func(*dnssec.Status) bool) *dnssec.Status {
n.t.Helper()
deadline := time.Now().Add(15 * time.Second)
for {
st := n.state()
s := n.e.DNSSEC(n.ctx, st, st.Zones[z.ID])
if ok(s) {
return s
}
if time.Now().After(deadline) {
n.t.Fatalf("waiting for %s: %+v (phase %+v)", what, s, s.Phase)
}
time.Sleep(200 * time.Millisecond)
}
}
func TestDNSSECWithRealNamed(t *testing.T) {
n := newNamedEnv(t)
z := n.state().ZonesNamed("example.test")[0]
zoneFile := filepath.Join(n.dir, "zones/_default/example.test.zone")
// Switching signing on for an existing zone: named makes a key and
// signs the zone (inline signing, keeping Jarl's file as it is).
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
conf, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
if !strings.Contains(string(conf), `dnssec-policy "default";`) || !strings.Contains(string(conf), `key-directory "`+filepath.Join(n.dir, "zones/dnssec-keys/_default")+`";`) {
t.Errorf("named.conf:\n%s", conf)
}
s := n.waitPhase(z, "signing", func(s *dnssec.Status) bool { return len(s.Keys) == 1 && s.Named != nil && s.Named.Secure })
k := s.Keys[0]
if s.Phase.Code != dnssec.PhaseSigning || k.Role != "CSK" || k.Algorithm != 13 || k.DS == nil || s.Phase.DSReadyAt == nil {
t.Errorf("status: %+v, key %+v", s.Phase, k)
}
if got := n.dig("example.test", "DNSKEY"); !strings.HasPrefix(got, "257 3 13 ") {
t.Errorf("DNSKEY = %q", got)
}
// Changes still load (Jarl's serial is the unsigned zone's), and the
// serial check sees the signed zone's serial.
r := findRec(n.state(), "www.example.test.", "A")
n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
if got := n.dig("www.example.test", "A"); got != "192.0.2.12" {
t.Errorf("www = %q", got)
}
cur := n.state()
served, ok, err := n.e.LoadedSerial(n.ctx, cur, cur.Zones[z.ID])
if !ok || err != nil || !serialGreater(served, cur.Zones[z.ID].SOA.Serial) {
t.Errorf("served serial %d (%v %v), Jarl's %d", served, ok, err, cur.Zones[z.ID].SOA.Serial)
}
// Unsafe steps are refused.
if e := n.submitErr(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})); !strings.Contains(e.Message, "switching DNSSEC off at once") {
t.Errorf("switching off directly: %v", e)
}
if e := n.submitErr(n.admin, NewRequest(OpZoneDelete, z.ID, nil)); !strings.Contains(e.Message, "turn DNSSEC off before deleting") {
t.Errorf("deleting: %v", e)
}
r = findRec(n.state(), "www.example.test.", "A")
if e := n.submitErr(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{TTL: model.Some(uint32(172800))})); !strings.Contains(e.Message, "max-zone-ttl") {
t.Errorf("long TTL: %v", e)
}
// A restore of an older database: Jarl's serial is now below named's,
// which named refuses for a signed zone. Resync raises it.
before := n.state()
older := before.Clone()
older.Zones[z.ID].SOA.Serial -= 5
findRec(older, "www.example.test.", "A").Data = "192.0.2.10"
if err := n.st.InTx(n.ctx, func(tx *store.Tx) error { return tx.SaveState(before, older) }); err != nil {
t.Fatal(err)
}
res, err := n.e.Resync(n.ctx, n.admin, ApplyOptions{Force: true})
if err != nil {
t.Fatalf("resync: %v", err)
}
if len(res.Serials) != 1 || !serialGreater(res.Serials[0].New, before.Zones[z.ID].SOA.Serial) {
t.Errorf("serials: %+v (named had %d)", res.Serials, before.Zones[z.ID].SOA.Serial)
}
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
t.Errorf("www after restore = %q", got)
}
if n.state().Zones[z.ID].SOA.Serial != res.Serials[0].New {
t.Error("raised serial not saved")
}
// Turning off: insecure first. The DS was never at the parent, so
// named removes the keys at once and finishing is allowed.
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("insecure")}))
n.waitPhase(z, "removal", func(s *dnssec.Status) bool { return s.Phase.CanFinish })
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")}))
if got := n.dig("example.test", "DNSKEY"); got != "" {
t.Errorf("DNSKEY after switching off = %q", got)
}
s = n.waitPhase(z, "leftover keys", func(s *dnssec.Status) bool { return true })
if s.Phase.Code != dnssec.PhaseLeftover {
t.Errorf("after switching off: %+v", s.Phase)
}
// Signing again makes a new key (the old one stays retired); a wipe
// switches off at once and deletes keys and signed files.
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
s = n.waitPhase(z, "signing again", func(s *dnssec.Status) bool { return len(s.Keys) == 2 && s.Named != nil && s.Named.Secure })
if s.Phase.Code != dnssec.PhaseSigning || s.Keys[0].Tag == k.Tag && s.Keys[0].Goal == dnssec.Omnipresent {
t.Errorf("signing again: %+v %+v", s.Phase, s.Keys[0])
}
if _, err := os.Stat(zoneFile + ".signed"); err != nil {
t.Errorf("signed copy: %v", err)
}
w, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{})
if err != nil {
t.Fatalf("wipe: %v", err)
}
if len(w.Removed) < 4 || w.Result == nil || !w.Result.Applied {
t.Errorf("wipe: %+v", w)
}
if files, _ := dnssec.Files(render.KeyDir(n.state(), z, n.e.cfg.Paths), z.Name); len(files) != 0 {
t.Errorf("keys left: %v", files)
}
if _, err := os.Stat(zoneFile + ".signed"); !os.IsNotExist(err) {
t.Errorf("signed copy left: %v", err)
}
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
t.Errorf("www after wipe = %q", got)
}
if _, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}); err == nil {
t.Error("a second wipe found something")
}
// Deleting the zone also removes named's journal.
n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil))
if left, _ := filepath.Glob(zoneFile + "*"); len(left) != 0 {
t.Errorf("left after deleting the zone: %v", left)
}
}