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

+256 -46
View File
@@ -9,6 +9,8 @@ import (
"sync"
"github.com/miekg/dns"
"jarl/internal/dnssec"
)
// Finding is one conclusion of a delegation check. Level is "ok",
@@ -28,8 +30,73 @@ type ParentAnswer struct {
NS []string `json:"ns,omitempty"`
// Glue is the addresses the parent hands out for in-zone name
// servers.
Glue map[string][]string `json:"glue,omitempty"`
Error string `json:"error,omitempty"`
Glue map[string][]string `json:"glue,omitempty"`
// DS is the zone's DS records at this server ("" in DSError when it
// couldn't be asked).
DS []dnssec.DS `json:"ds,omitempty"`
DSError string `json:"ds_error,omitempty"`
Error string `json:"error,omitempty"`
}
// Signing is how Jarl signs a zone, for comparing with the parent's DS
// records.
type Signing struct {
// Policy is the zone's dnssec-policy ("" when unsigned, "insecure"
// while signing is being removed).
Policy string
// DS are the DS records of the zone's key-signing keys that named
// still publishes.
DS []dnssec.DS
}
// ParentDSReport is what the parent zone's servers say about the zone's
// DS records.
type ParentDSReport struct {
Zone string `json:"zone"`
Parent string `json:"parent,omitempty"`
Resolver string `json:"resolver,omitempty"`
// Delegated: at least one parent server delegates the zone. Reached
// is how many answered.
Delegated bool `json:"delegated"`
Reached int `json:"reached"`
Servers []ParentAnswer `json:"parent_servers"`
// DS is every DS record any parent server has.
DS []dnssec.DS `json:"ds"`
// Consistent: every server that answered has the same DS records.
Consistent bool `json:"consistent"`
Error string `json:"error,omitempty"`
}
// AllHaveDS reports whether every parent server that answered has a DS
// record for key tag (with digest, unless "").
func (r *ParentDSReport) AllHaveDS(tag uint16, digest string) bool {
n := 0
for _, a := range r.Servers {
if a.Status != "delegated" || a.DSError != "" {
continue
}
n++
found := false
for _, d := range a.DS {
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
found = true
}
}
if !found {
return false
}
}
return n > 0
}
// HasDS reports whether a parent server has a DS record for key tag.
func (r *ParentDSReport) HasDS(tag uint16, digest string) bool {
for _, d := range r.DS {
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
return true
}
}
return false
}
// GlueCheck compares the parent's glue for an in-zone name server with
@@ -52,6 +119,8 @@ type DelegationReport struct {
JarlNS []string `json:"jarl_ns"`
Parents []ParentAnswer `json:"parent_servers"`
Glue []GlueCheck `json:"glue"`
// ParentDS are the zone's DS records at the parent (DNSSEC).
ParentDS []dnssec.DS `json:"parent_ds"`
// Servers are the delegated name servers' SOA answers, as resolvers
// on the internet would reach them.
Servers []Result `json:"servers"`
@@ -60,12 +129,14 @@ type DelegationReport struct {
// Delegation checks how the parent zone delegates zone: it finds the
// parent through the resolver, asks each of the parent's servers for the
// zone's NS records and glue, compares those with jarlNS and jarlAddrs
// (Jarl's NS records and its addresses for in-zone name servers), and
// asks every delegated server for the zone's serial (compared with ref).
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32) *DelegationReport {
// zone's NS records, glue and DS records, compares those with jarlNS and
// jarlAddrs (Jarl's NS records and its addresses for in-zone name
// servers) and with sig (how Jarl signs the zone; nil skips the DNSSEC
// part), and asks every delegated server for the zone's serial (compared
// with ref).
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32, sig *Signing) *DelegationReport {
zone = dns.CanonicalName(zone)
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, Servers: []Result{}, Findings: []Finding{}}
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, ParentDS: []dnssec.DS{}, Servers: []Result{}, Findings: []Finding{}}
sort.Strings(rep.JarlNS)
add := func(level, format string, args ...any) {
for i, a := range args {
@@ -78,42 +149,17 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
}
rep.Findings = append(rep.Findings, Finding{level, fmt.Sprintf(format, args...)})
}
res, err := c.resolver()
if err != nil {
add("error", "No resolver to find the parent zone with: %v", err)
pr := c.ParentDS(ctx, zone)
rep.Resolver, rep.Parent, rep.Parents = pr.Resolver, pr.Parent, pr.Servers
if pr.Error != "" {
level := "error"
if zone == "." {
level = "info"
}
rep.Findings = append(rep.Findings, Finding{level, pr.Error})
return rep
}
rep.Resolver = res
if zone == "." {
add("info", "The root zone has no parent to delegate it.")
return rep
}
// The parent zone: the zone that holds the name just above ours.
parent, err := c.enclosingZone(ctx, res, parentName(zone))
if err != nil {
add("error", "Couldn't find the parent zone of %s through %s: %v", zone, res, err)
return rep
}
rep.Parent = parent
pns, err := c.nsNames(ctx, res, parent)
if err != nil || len(pns) == 0 {
add("error", "Couldn't find the name servers of the parent zone %s through %s: %v", parent, res, errOr(err, "none listed"))
return rep
}
// Ask every parent server, in parallel.
var wg sync.WaitGroup
answers := make([]ParentAnswer, len(pns))
for i, name := range pns {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
answers[i] = c.askParent(ctx, zone, name)
}(i, name)
}
wg.Wait()
rep.Parents = answers
parent, answers := pr.Parent, pr.Servers
parentNS := map[string]bool{}
glue := map[string]map[string]bool{}
@@ -141,10 +187,6 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
notDelegated++
}
}
if reached == 0 {
add("error", "None of the parent zone's servers (%s) answered; this server may not be able to reach them.", pns)
return rep
}
if delegated == 0 {
rep.Delegated = false
where := "the parent zone " + show(parent)
@@ -154,6 +196,7 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
add("info", "%s isn't delegated from %s, so the internet can't see it. That's expected for a zone used only on your own network. To publish it, register the domain and give your registrar these name servers: %s.", zone, where, rep.JarlNS)
return rep
}
rep.ParentDS = pr.DS
rep.Delegated = true
if notDelegated > 0 {
add("warning", "%d of the parent's %d servers don't know the delegation yet (a recent change at the registrar can take a while to reach them all).", notDelegated, reached)
@@ -207,6 +250,12 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
}
}
if sig != nil {
for _, f := range dsFindings(zone, pr, sig) {
rep.Findings = append(rep.Findings, f)
}
}
// Every delegated server, reached the way resolvers reach it: by its
// glue, or by looking its name up.
var targets []Target
@@ -262,7 +311,151 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
return rep
}
// askParent asks one of the parent's servers (by name) for zone's NS.
// ParentDS finds the parent of zone through the resolver and asks each of
// its servers for the zone's delegation and DS records.
func (c *Checker) ParentDS(ctx context.Context, zone string) *ParentDSReport {
zone = dns.CanonicalName(zone)
rep := &ParentDSReport{Zone: zone, Servers: []ParentAnswer{}, DS: []dnssec.DS{}}
res, err := c.resolver()
if err != nil {
rep.Error = "No resolver to find the parent zone with: " + err.Error()
return rep
}
rep.Resolver = res
if zone == "." {
rep.Error = "The root zone has no parent to delegate it."
return rep
}
// The parent zone: the zone that holds the name just above ours.
parent, err := c.enclosingZone(ctx, res, parentName(zone))
if err != nil {
rep.Error = fmt.Sprintf("Couldn't find the parent zone of %s through %s: %v", show(zone), res, err)
return rep
}
rep.Parent = parent
pns, err := c.nsNames(ctx, res, parent)
if err != nil || len(pns) == 0 {
rep.Error = fmt.Sprintf("Couldn't find the name servers of the parent zone %s through %s: %v", show(parent), res, errOr(err, "none listed"))
return rep
}
// Ask every parent server, in parallel.
var wg sync.WaitGroup
answers := make([]ParentAnswer, len(pns))
for i, name := range pns {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
answers[i] = c.askParent(ctx, zone, name)
}(i, name)
}
wg.Wait()
rep.Servers = answers
all := map[string]dnssec.DS{}
sets := map[string]bool{}
for _, a := range answers {
if a.Status != "delegated" && a.Status != "not_delegated" {
continue
}
rep.Reached++
if a.Status == "delegated" {
rep.Delegated = true
}
if a.DSError != "" {
continue
}
var set []string
for _, d := range a.DS {
k := dsKey(d)
all[k] = d
set = append(set, k)
}
sort.Strings(set)
sets[strings.Join(set, " ")] = true
}
if rep.Reached == 0 {
names := make([]string, len(pns))
for i, n := range pns {
names[i] = show(n)
}
rep.Error = fmt.Sprintf("None of the parent zone's servers (%s) answered; this server may not be able to reach them.", strings.Join(names, ", "))
return rep
}
for _, k := range keys(boolSet(all)) {
rep.DS = append(rep.DS, all[k])
}
rep.Consistent = len(sets) <= 1
return rep
}
func dsKey(d dnssec.DS) string {
return fmt.Sprintf("%05d %d %d %s", d.KeyTag, d.Algorithm, d.DigestType, strings.ToUpper(d.Digest))
}
func boolSet(m map[string]dnssec.DS) map[string]bool {
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}
// dsFindings compares the parent's DS records with how Jarl signs the
// zone.
func dsFindings(zone string, pr *ParentDSReport, sig *Signing) []Finding {
var out []Finding
add := func(level, format string, args ...any) {
out = append(out, Finding{level, fmt.Sprintf(format, args...)})
}
z := show(zone)
var tags []string
for _, d := range pr.DS {
tags = append(tags, fmt.Sprint(d.KeyTag))
}
if !pr.Consistent {
add("warning", "The parent's servers have different DS records for %s (a change at the registrar may still be on its way to them all).", z)
}
matches := func(d dnssec.DS) bool {
for _, k := range sig.DS {
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
return true
}
}
return false
}
var good, stale []string
for _, d := range pr.DS {
if matches(d) {
good = append(good, fmt.Sprint(d.KeyTag))
} else {
stale = append(stale, fmt.Sprint(d.KeyTag))
}
}
switch {
case len(pr.DS) == 0 && sig.Policy == "":
// Unsigned and no DS: nothing to say about DNSSEC.
case len(pr.DS) == 0 && sig.Policy == "insecure":
add("ok", "The parent has no DS record for %s any more, so turning DNSSEC off can go ahead.", z)
case len(pr.DS) == 0:
add("info", "%s is signed, but the parent has no DS record for it, so resolvers treat it as unsigned. Add the DS record at your registrar when the zone's DNSSEC page says it's ready.", z)
case sig.Policy == "":
add("error", "The parent has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject all of its answers. Remove the DS record at your registrar now.", z, strings.Join(tags, ", "))
case len(good) == 0:
add("error", "The parent's DS record for %s (key %s) matches none of the zone's keys: validating resolvers reject all of its answers. Replace it at your registrar with the zone's DS record.", z, strings.Join(tags, ", "))
default:
if sig.Policy == "insecure" {
add("warning", "The parent still has the DS record for %s (key %s). DNSSEC is being turned off: remove it at your registrar, then run the DS check.", z, strings.Join(good, ", "))
} else {
add("ok", "The parent's DS record matches the zone's key %s: validating resolvers check its answers.", strings.Join(good, ", "))
}
if len(stale) > 0 {
add("warning", "The parent also has a DS record for key %s, which the zone no longer has. Remove it at your registrar.", strings.Join(stale, ", "))
}
}
return out
}
// askParent asks one of the parent's servers (by name) for zone's NS and
// DS records.
func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnswer {
pa := ParentAnswer{Name: server}
addrs, err := c.lookup(ctx, server)
@@ -331,6 +524,23 @@ func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnsw
for n := range pa.Glue {
pa.Glue[n] = canonAddrs(pa.Glue[n])
}
// The parent itself answers DS queries for the zone.
m = new(dns.Msg)
m.SetQuestion(zone, dns.TypeDS)
m.RecursionDesired = false
resp, _, err = c.exchange(ctx, m, pa.Address, nil)
switch {
case err != nil:
pa.DSError = err.Error()
case resp.Rcode != dns.RcodeSuccess:
pa.DSError = "answered " + dns.RcodeToString[resp.Rcode]
default:
for _, rr := range resp.Answer {
if d, ok := rr.(*dns.DS); ok && dns.CanonicalName(d.Hdr.Name) == zone {
pa.DS = append(pa.DS, dnssec.DS{KeyTag: d.KeyTag, Algorithm: d.Algorithm, DigestType: d.DigestType, Digest: strings.ToUpper(d.Digest), Record: d.String()})
}
}
}
return pa
}
+67 -3
View File
@@ -10,6 +10,8 @@ import (
"time"
"github.com/miekg/dns"
"jarl/internal/dnssec"
)
// fake is a DNS server on one loopback address that answers with fn.
@@ -187,7 +189,7 @@ func TestDelegationMismatch(t *testing.T) {
ref := uint32(7)
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.",
[]string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref)
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref, nil)
if rep.Parent != "lol." || !rep.Delegated {
t.Fatalf("report: %+v", rep)
}
@@ -218,7 +220,7 @@ func TestDelegationGood(t *testing.T) {
return m
})
ref := uint32(7)
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref)
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
l := levels(rep)
if len(l["error"])+len(l["warning"])+len(l["info"]) != 0 || len(l["ok"]) != 2 {
t.Errorf("findings: %v", l)
@@ -234,7 +236,7 @@ func TestNotDelegated(t *testing.T) {
m.Ns = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
return m
})
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil)
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil, nil)
l := levels(rep)
if rep.Delegated || len(l["info"]) != 1 || len(l["warning"])+len(l["error"]) != 0 {
t.Errorf("findings: %v", l)
@@ -243,3 +245,65 @@ func TestNotDelegated(t *testing.T) {
t.Errorf("note: %s", l["info"][0])
}
}
func TestDelegationDS(t *testing.T) {
const dsData = "22642 13 2 0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A"
parent := func(withDS bool) fake {
return func(q dns.Question) *dns.Msg {
m := new(dns.Msg)
switch {
case q.Qtype == dns.TypeNS && q.Name == "adbraun.lol.":
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
case q.Qtype == dns.TypeDS && q.Name == "adbraun.lol.":
m.Authoritative = true
if withDS {
m.Answer = []dns.RR{rr("adbraun.lol. 3600 IN DS " + dsData)}
}
}
return m
}
}
ours := dnssec.DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: strings.Fields(dsData)[3]}
other := ours
other.KeyTag, other.Digest = 1111, strings.Repeat("AB", 32)
ref := uint32(7)
for _, c := range []struct {
name string
withDS bool
sig Signing
level string
want string
}{
{"secure", true, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "ok", "matches the zone's key 22642"},
{"wrong key", true, Signing{Policy: "default", DS: []dnssec.DS{other}}, "error", "matches none of the zone's keys"},
{"unsigned", true, Signing{}, "error", "but the zone isn't signed"},
{"turning off", true, Signing{Policy: "insecure", DS: []dnssec.DS{ours}}, "warning", "remove it at your registrar"},
{"no DS yet", false, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "info", "the parent has no DS record for it"},
{"DS gone", false, Signing{Policy: "insecure"}, "ok", "turning DNSSEC off can go ahead"},
} {
port, res := internet(t, parent(c.withDS))
sig := c.sig
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &sig)
found := false
for _, f := range rep.Findings {
if f.Level == c.level && strings.Contains(f.Message, c.want) {
found = true
}
}
if !found {
t.Errorf("%s: no %s finding %q in %+v", c.name, c.level, c.want, rep.Findings)
}
if c.withDS && (len(rep.ParentDS) != 1 || rep.ParentDS[0].KeyTag != 22642) {
t.Errorf("%s: parent DS %+v", c.name, rep.ParentDS)
}
}
// Unsigned and no DS: nothing about DNSSEC.
port, res := internet(t, parent(false))
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &Signing{})
for _, f := range rep.Findings {
if strings.Contains(f.Message, "DS") {
t.Errorf("unsigned zone: %+v", f)
}
}
}