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>
143 lines
5.0 KiB
Go
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
|
|
}
|