Files
jarl/internal/validate/dnssec.go
T
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

143 lines
5.0 KiB
Go

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
}