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

+142
View File
@@ -0,0 +1,142 @@
package validate
import (
"regexp"
"strconv"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// defaultMaxZoneTTL is max-zone-ttl of BIND's built-in policies (and of
// custom ones that don't set it): named refuses to load a signed zone
// with a longer TTL.
const defaultMaxZoneTTL = 86400
// Policies returns the custom dnssec-policy statements defined at the top
// level, by name.
func Policies(s *model.State) map[string]*namedconf.Stmt {
out := map[string]*namedconf.Stmt{}
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
if strings.ToLower(o.Name) != "dnssec-policy" {
continue
}
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || stmt.Arg(0) == "" {
continue
}
out[stmt.Arg(0)] = stmt
}
return out
}
func (c *checker) checkDNSSEC() {
policies := Policies(c.s)
// A custom policy without keys crashes named 9.20 when it loads the
// configuration (an assertion in kaspconf.c), although named-checkconf
// accepts it. BIND's built-in "insecure" policy is the way to go
// unsigned.
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
if strings.ToLower(o.Name) != "dnssec-policy" {
continue
}
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
continue // reported by checkOptions
}
if b := stmt.FirstBlock(); b != nil {
if keys := namedconf.FindOne(b.Stmts, "keys"); keys != nil {
if kb := keys.FirstBlock(); kb != nil && len(kb.Stmts) == 0 {
c.errf("dnssec_policy_no_keys", "option", o.ID, "dnssec-policy %s has an empty keys list, which makes named 9.20 crash when it loads the configuration; to remove signing from a zone, use BIND's built-in insecure policy (jarl zone dnssec disable)", stmt.Arg(0))
}
}
}
}
for _, z := range c.s.SortedZones() {
if z.DNSSECPolicy == "" {
continue
}
if z.Type != model.ZonePrimary {
c.zoneProblem(z, Error, "dnssec_zone_type", "only primary zones can be signed by Jarl; a %s zone gets its signatures from its primary", z.Type)
continue
}
maxTTL := uint32(defaultMaxZoneTTL)
switch z.DNSSECPolicy {
case model.DNSSECDefault, model.DNSSECInsecure:
default:
stmt := policies[z.DNSSECPolicy]
if stmt == nil {
c.zoneProblem(z, Error, "dnssec_policy_unknown", "dnssec-policy %q doesn't exist: use default (BIND's built-in policy), or define it as a top-level dnssec-policy statement", z.DNSSECPolicy)
continue
}
if b := stmt.FirstBlock(); b != nil {
if m := namedconf.FindOne(b.Stmts, "max-zone-ttl"); m != nil {
if v, ok := parseDuration(m.Arg(0)); ok && v > 0 {
maxTTL = v
}
}
}
}
if o := c.s.FindOption(model.ScopeZone, z.ID, "inline-signing"); o != nil && isNo(o.Value) {
c.zoneProblem(z, Error, "dnssec_inline_signing", "a signed zone needs inline signing: named keeps its signatures in a separate copy of the zone, because Jarl rewrites the zone file (remove the inline-signing option)")
}
// named refuses to load a signed zone with a TTL above the
// policy's max-zone-ttl, and keeps serving the old version.
if z.DefaultTTL > maxTTL {
c.zoneProblem(z, Error, "dnssec_ttl_too_long", "the default TTL (%d) is longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", z.DefaultTTL, maxTTL, maxTTL)
}
for _, r := range c.zoneRecords[z.ID] {
if r.TTL != nil && *r.TTL > maxTTL {
p := c.zoneProblem(z, Error, "dnssec_ttl_too_long", "%s %s has TTL %d, longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", strings.TrimSuffix(r.Name, "."), r.Type, *r.TTL, maxTTL, maxTTL)
p.ObjectType, p.ObjectID, p.Name = "record", r.ID, r.Name
}
}
}
}
func isNo(v string) bool {
switch strings.ToLower(strings.Trim(strings.TrimSpace(v), `";`)) {
case "no", "false", "0":
return true
}
return false
}
var (
isoDuration = regexp.MustCompile(`^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$`)
ttlDuration = regexp.MustCompile(`^(?:\d+[wdhms])+$`)
ttlPart = regexp.MustCompile(`(\d+)([wdhms])`)
)
// parseDuration parses a BIND duration: ISO 8601 (P1D, PT12H), seconds, or
// TTL style (1d, 12h30m).
func parseDuration(s string) (uint32, bool) {
s = strings.ToUpper(strings.TrimSpace(s))
if n, err := strconv.ParseUint(s, 10, 32); err == nil {
return uint32(n), true
}
if m := isoDuration.FindStringSubmatch(s); m != nil && s != "P" && s != "PT" {
units := []uint64{31536000, 2592000, 604800, 86400, 3600, 60, 1}
var total uint64
for i, u := range units {
if m[i+1] != "" {
n, _ := strconv.ParseUint(m[i+1], 10, 32)
total += n * u
}
}
return uint32(min(total, 1<<32-1)), true
}
l := strings.ToLower(s)
if ttlDuration.MatchString(l) {
units := map[string]uint64{"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
var total uint64
for _, m := range ttlPart.FindAllStringSubmatch(l, -1) {
n, _ := strconv.ParseUint(m[1], 10, 32)
total += n * units[m[2]]
}
return uint32(min(total, 1<<32-1)), true
}
return 0, false
}
+70
View File
@@ -0,0 +1,70 @@
package validate
import (
"testing"
"jarl/internal/model"
)
func TestDNSSEC(t *testing.T) {
b := newBuilder()
ok := b.primary("signed.example", nil)
ok.DNSSECPolicy = model.DNSSECDefault
ps := State(b.s, Options{})
if len(codes(ps, Error)) != 0 {
t.Fatalf("a signed zone: %v", ps)
}
sec := b.zone("copy.example", nil, model.ZoneSecondary)
b.opt(model.ScopeZone, sec.ID, "primaries", "{ 192.0.2.1; }")
sec.DNSSECPolicy = model.DNSSECDefault
unknown := b.primary("custom.example", nil)
unknown.DNSSECPolicy = "fast"
long := b.primary("long.example", nil)
long.DNSSECPolicy = model.DNSSECInsecure
r := b.rec(long, "www", "A", "192.0.2.80")
r.TTL = model.Ptr(uint32(172800))
b.opt(model.ScopeZone, ok.ID, "inline-signing", "no")
ps = State(b.s, Options{})
for _, c := range []string{"dnssec_zone_type", "dnssec_policy_unknown", "dnssec_ttl_too_long", "dnssec_inline_signing"} {
if !hasCode(ps, c) {
t.Errorf("missing %s in %v", c, ps)
}
}
// A custom policy: its max-zone-ttl applies; an empty keys list would
// crash named.
b2 := newBuilder()
z := b2.primary("custom.example", nil)
z.DNSSECPolicy = "fast"
z.DefaultTTL = 600
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `fast { keys { csk lifetime unlimited algorithm 13; }; max-zone-ttl PT5M; }`)
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `"off" { keys { }; }`)
ps = State(b2.s, Options{})
if !hasCode(ps, "dnssec_ttl_too_long") || !hasCode(ps, "dnssec_policy_no_keys") || hasCode(ps, "dnssec_policy_unknown") {
t.Errorf("custom policy: %v", ps)
}
// dnssec-policy is set per zone in Jarl, not as an option.
b3 := newBuilder()
z = b3.primary("example.com", nil)
b3.opt(model.ScopeZone, z.ID, "dnssec-policy", "default")
b3.opt(model.ScopeOptions, 0, "dnssec-policy", "default")
b3.opt(model.ScopeZone, z.ID, "key-directory", `"/tmp"`)
if n := len(codes(State(b3.s, Options{}), Error)); n != 3 {
t.Errorf("managed options: %v", State(b3.s, Options{}))
}
}
func TestParseDuration(t *testing.T) {
for in, want := range map[string]uint32{"86400": 86400, "P1D": 86400, "PT5M": 300, "P1W": 604800, "1d": 86400, "12h30m": 45000, "pt1h": 3600} {
if got, ok := parseDuration(in); !ok || got != want {
t.Errorf("parseDuration(%q) = %d %v, want %d", in, got, ok, want)
}
}
for _, bad := range []string{"", "P", "unlimited", "1x"} {
if _, ok := parseDuration(bad); ok {
t.Errorf("parseDuration(%q) accepted", bad)
}
}
}
+1
View File
@@ -135,6 +135,7 @@ func State(s *model.State, opts Options) []Problem {
c.checkZones()
c.checkOptions()
c.checkSecurity()
c.checkDNSSEC()
sortProblems(c.problems)
return c.problems
}