Files
abrown38andClaude Opus 5.5 5630e7a848 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>
2026-10-08 21:19:04 +00:00

171 lines
6.8 KiB
Go

package dnssec
import (
"fmt"
"strings"
"time"
)
// Phases of a zone, from unsigned to signed and back. Turning signing on:
// off -> starting -> signing -> ready (add the DS at the parent) ->
// ds_pending -> secure. Turning it off: secure -> removing_ds (remove the
// DS at the parent) -> ds_expiring -> removing_keys -> unsigned -> off.
const (
PhaseOff = "off" // not signed, no keys
PhaseLeftover = "leftover_keys" // not signed, but old keys are still in the key directory
PhaseStarting = "starting" // signing is on in Jarl; named hasn't made keys yet
PhaseSigning = "signing" // keys made, zone signed; not yet safe to add the DS
PhaseReady = "ready" // safe to add the DS at the parent
PhaseDSPending = "ds_pending" // the DS is at the parent; named waits for its TTL
PhaseSecure = "secure" // signed, and the parent's DS is in place
PhaseRemovingDS = "removing_ds" // turning off: the DS must go from the parent first
PhaseDSExpiring = "ds_expiring" // the DS is gone; named waits until caches forget it
PhaseRemovingKeys = "removing_keys" // named removes the keys and signatures
PhaseUnsigned = "unsigned" // turning off has finished; switch DNSSEC off
)
// Phase says where a zone is and what comes next.
type Phase struct {
Code string `json:"code"`
Summary string `json:"summary"`
Next string `json:"next,omitempty"`
// DSReadyAt is when the DS can safely be added at the parent (phase
// signing).
DSReadyAt *time.Time `json:"ds_ready_at,omitempty"`
// CanPublishDS: the zone is ready for (or already has) a DS at the
// parent.
CanPublishDS bool `json:"can_publish_ds"`
// CanFinish: the zone is being turned off and the parent has no DS
// any more (as far as named knows), so DNSSEC can be switched off.
CanFinish bool `json:"can_finish"`
// NeedsDS lists the signing keys whose DS should be at the parent,
// and StaleDS those whose DS should be removed from it.
NeedsDS []uint16 `json:"needs_ds,omitempty"`
StaleDS []uint16 `json:"stale_ds,omitempty"`
}
// Assess works out the phase of a zone signed with policy ("" when Jarl
// doesn't sign it, "insecure" while signing is being removed) that has
// keys.
func Assess(policy string, keys []*Key) Phase {
var live []*Key // signing keys named keeps (or is introducing)
for _, k := range keys {
if k.SigningKey() && k.Goal == Omnipresent {
live = append(live, k)
}
}
switch {
case policy == "":
if len(keys) == 0 {
return Phase{Code: PhaseOff, Summary: "DNSSEC is off."}
}
return Phase{Code: PhaseLeftover, Summary: "DNSSEC is off, but keys from when the zone was signed are still in its key directory.",
Next: "Wipe them (jarl zone dnssec wipe) once you're sure the parent zone has no DS record for them, or turn signing on again to reuse them."}
case policy == "insecure":
return assessOff(keys)
}
if len(live) == 0 {
return Phase{Code: PhaseStarting, Summary: "Signing is on; named hasn't created the zone's keys yet.",
Next: "Wait a moment: named makes the keys when it loads the configuration."}
}
p := Phase{}
var notReady []*Key
for _, k := range live {
if k.DNSKEYState != Omnipresent || k.KeyRRSIG != Omnipresent {
notReady = append(notReady, k)
}
}
for _, k := range keys {
if k.SigningKey() && k.Goal == Hidden && k.DSState != Hidden && k.DSRemoved == nil {
p.StaleDS = append(p.StaleDS, k.Tag)
}
}
if len(notReady) > 0 {
p.Code = PhaseSigning
p.Summary = "named is signing the zone. Resolvers still treat it as unsigned, because the parent zone has no DS record for it."
for _, k := range notReady {
if k.PublishCDS != nil && (p.DSReadyAt == nil || k.PublishCDS.After(*p.DSReadyAt)) {
t := *k.PublishCDS
p.DSReadyAt = &t
}
}
if p.DSReadyAt != nil {
p.Next = fmt.Sprintf("Wait until %s before adding the DS at the parent: until then some resolvers may still have unsigned answers cached, and would reject them once the DS appears.", p.DSReadyAt.UTC().Format("Mon 2 Jan 15:04 MST"))
} else {
p.Next = "Wait until named has published the keys long enough for caches to pick them up before adding the DS."
}
return p
}
p.CanPublishDS = true
var pending, secure int
for _, k := range live {
switch {
case k.DSState == Omnipresent:
secure++
case k.DSPublished != nil:
pending++
default:
p.NeedsDS = append(p.NeedsDS, k.Tag)
}
}
switch {
case len(p.NeedsDS) > 0:
p.Code = PhaseReady
p.Summary = "The zone is signed and ready: add the DS record at the parent zone (your registrar) to turn validation on."
p.Next = "Add the DS record (" + tags(p.NeedsDS) + ") at your registrar, then run the DS check so named knows it's there."
case pending > 0:
p.Code = PhaseDSPending
p.Summary = "The parent zone has the DS record. named waits for the parent's TTL before it treats it as in place."
default:
p.Code = PhaseSecure
p.Summary = "The zone is signed and the parent zone's DS record points at its key: validating resolvers check its answers."
}
if len(p.StaleDS) > 0 && p.Next == "" {
p.Next = "Remove the DS record of the retired key " + tags(p.StaleDS) + " at your registrar, then run the DS check."
}
return p
}
func assessOff(keys []*Key) Phase {
var dsUp, dsGoing, keysLeft bool
for _, k := range keys {
if !k.SigningKey() {
if !k.Gone() {
keysLeft = true
}
continue
}
switch {
case k.DSState == Hidden || k.DSState == "":
case k.DSRemoved != nil:
dsGoing = true
default:
dsUp = true
}
if !k.Gone() {
keysLeft = true
}
}
switch {
case dsUp:
return Phase{Code: PhaseRemovingDS, Summary: "Turning DNSSEC off: named keeps the zone signed until the parent zone's DS record is gone.",
Next: "Remove the DS record at your registrar, then run the DS check so named knows. Don't switch DNSSEC off before that: the zone would fail to validate."}
case dsGoing:
return Phase{Code: PhaseDSExpiring, Summary: "Turning DNSSEC off: the parent's DS record is gone; named keeps signing until resolvers' cached copies of it expire (the parent's DS TTL, often a day).",
Next: "Wait: switching DNSSEC off now would make the zone fail to validate for resolvers that still have the DS cached."}
case keysLeft:
return Phase{Code: PhaseRemovingKeys, Summary: "Turning DNSSEC off: the parent has no DS any more, and named is removing the keys and signatures.",
Next: "Wait until named has finished, then switch DNSSEC off.", CanFinish: true}
}
return Phase{Code: PhaseUnsigned, Summary: "Turning DNSSEC off has finished: the zone is no longer signed.",
Next: "Switch DNSSEC off for the zone to finish (jarl zone dnssec disable), and wipe the old keys if you like.", CanFinish: true}
}
func tags(ts []uint16) string {
var s []string
for _, t := range ts {
s = append(s, fmt.Sprintf("key %d", t))
}
return strings.Join(s, ", ")
}