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>
135 lines
4.5 KiB
Go
135 lines
4.5 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"jarl/internal/model"
|
|
"jarl/internal/render"
|
|
"jarl/internal/validate"
|
|
)
|
|
|
|
// serialGreater reports whether a > b in RFC 1982 serial number
|
|
// arithmetic.
|
|
func serialGreater(a, b uint32) bool { return int32(a-b) > 0 }
|
|
|
|
// NextSerial returns the serial after old under policy, with the same
|
|
// semantics as BIND's serial-update-method.
|
|
func NextSerial(old uint32, policy string, now time.Time) uint32 {
|
|
switch policy {
|
|
case model.SerialUnixtime:
|
|
t := uint32(now.Unix())
|
|
if serialGreater(t, old) {
|
|
return t
|
|
}
|
|
return old + 1
|
|
case model.SerialIncrement:
|
|
return old + 1
|
|
default: // date: YYYYMMDDnn
|
|
d := dateSerial(now)
|
|
if serialGreater(d, old) {
|
|
return d
|
|
}
|
|
return old + 1
|
|
}
|
|
}
|
|
|
|
// SerialChange records a serial bump.
|
|
type SerialChange struct {
|
|
ZoneID int64 `json:"zone_id"`
|
|
Zone string `json:"zone"`
|
|
View string `json:"view,omitempty"`
|
|
Old uint32 `json:"old"`
|
|
New uint32 `json:"new"`
|
|
}
|
|
|
|
// bumpSerials increments the serial of every primary zone in work whose
|
|
// DNS data differs from base, unless the serial was set explicitly.
|
|
func bumpSerials(base, work *model.State, now time.Time, user string) []SerialChange {
|
|
var out []SerialChange
|
|
for _, z := range work.SortedZones() {
|
|
if z.Type != model.ZonePrimary {
|
|
continue
|
|
}
|
|
policy := z.SerialPolicy
|
|
if policy == "" {
|
|
policy = work.Settings.SerialPolicy
|
|
}
|
|
old := base.Zones[z.ID]
|
|
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
|
// New to named (or moved): keep the serial it was created
|
|
// with, which InitialSerial or the source zone provided.
|
|
continue
|
|
}
|
|
if z.SOA.Serial != old.SOA.Serial {
|
|
// Set explicitly by the user.
|
|
continue
|
|
}
|
|
if render.ZoneContentHash(base, old) == render.ZoneContentHash(work, z) {
|
|
continue
|
|
}
|
|
next := NextSerial(old.SOA.Serial, policy, now)
|
|
out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: old.SOA.Serial, New: next})
|
|
z.SOA.Serial = next
|
|
z.Touch(user, now)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// guardSerials makes sure every zone whose file is about to change gets
|
|
// a serial above the one named has loaded. Normally Jarl's serial is
|
|
// already higher, but not after restoring an older database or when the
|
|
// files on disk were replaced: named refuses to load a signed zone's file
|
|
// unless its serial went up (and keeps serving the old data), and
|
|
// secondaries ignore a zone whose serial went down. Zones are bumped past
|
|
// named's serial unless the user set the serial explicitly; an explicit
|
|
// serial that a signed zone can't take is an error. named is only asked
|
|
// when it controls the zone (not with no_reload).
|
|
func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bumped []SerialChange, user string) ([]SerialChange, []validate.Problem) {
|
|
if e.cfg.NoReload || e.tools == nil {
|
|
return nil, nil
|
|
}
|
|
auto := map[int64]bool{}
|
|
for _, c := range bumped {
|
|
auto[c.ZoneID] = true
|
|
}
|
|
var out []SerialChange
|
|
var problems []validate.Problem
|
|
for _, z := range work.SortedZones() {
|
|
if z.Type != model.ZonePrimary {
|
|
continue
|
|
}
|
|
old := base.Zones[z.ID]
|
|
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
|
continue // new to named: nothing loaded to compare with
|
|
}
|
|
path := render.ZoneFilePath(work, z, e.cfg.Paths)
|
|
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
|
|
continue // named keeps what it has
|
|
}
|
|
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(work, z))
|
|
if err != nil || !zs.HasSerial || serialGreater(z.SOA.Serial, zs.Serial) {
|
|
continue
|
|
}
|
|
explicit := z.SOA.Serial != old.SOA.Serial && !auto[z.ID]
|
|
if explicit {
|
|
if z.Signed() {
|
|
problems = append(problems, validate.Problem{Severity: validate.Error, Code: "serial_not_higher", ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: work.ViewName(z),
|
|
Message: fmt.Sprintf("named only loads a signed zone's file when its serial goes up, and it has serial %d loaded; set a higher serial (or leave it to Jarl)", zs.Serial)})
|
|
}
|
|
continue
|
|
}
|
|
policy := z.SerialPolicy
|
|
if policy == "" {
|
|
policy = work.Settings.SerialPolicy
|
|
}
|
|
next := NextSerial(zs.Serial, policy, e.Now())
|
|
out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: z.SOA.Serial, New: next})
|
|
e.log.Info("raising a zone's serial above the one named has loaded", "zone", z.Name, "view", work.ViewName(z), "jarl", z.SOA.Serial, "named", zs.Serial, "new", next)
|
|
z.SOA.Serial = next
|
|
z.Touch(user, e.Now())
|
|
}
|
|
return out, problems
|
|
}
|