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