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:
1 parent
293e6ddd33
commit
5630e7a848
72 files changed
+5760
-153
No files matched your search
@@ -60,7 +60,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
n := to.Zones[k]
|
||||
s := n.SOA
|
||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, Comment: &n.Comment}
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
|
||||
if n.Type == model.ZonePrimary {
|
||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package engine
|
||||
|
||||
// DNSSEC signing (docs/dnssec.md). named signs zones itself (dnssec-policy
|
||||
// with inline signing) and keeps their keys in a key directory Jarl
|
||||
// chooses (render.KeyDir). Jarl's part is to keep the steps in order: a
|
||||
// zone must never become unsigned while the parent zone may still have a
|
||||
// DS record for it, or validating resolvers reject the whole domain.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// DNSSEC returns the signing status of zone z.
|
||||
func (e *Engine) DNSSEC(ctx context.Context, st *model.State, z *model.Zone) *dnssec.Status {
|
||||
s := &dnssec.Status{Policy: z.DNSSECPolicy, KeyDir: render.KeyDir(st, z, e.cfg.Paths), Keys: []*dnssec.Key{}, Policies: []string{model.DNSSECDefault}}
|
||||
for name := range validate.Policies(st) {
|
||||
s.Policies = append(s.Policies, name)
|
||||
}
|
||||
sort.Strings(s.Policies[1:])
|
||||
keys, err := dnssec.Keys(s.KeyDir, z.Name)
|
||||
if err != nil {
|
||||
s.KeyError = err.Error()
|
||||
} else if keys != nil {
|
||||
s.Keys = keys
|
||||
}
|
||||
s.Phase = dnssec.Assess(z.DNSSECPolicy, s.Keys)
|
||||
if z.Type == model.ZonePrimary && !e.cfg.NoReload && e.tools != nil {
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(st, z))
|
||||
if err != nil {
|
||||
s.NamedError = err.Error()
|
||||
} else {
|
||||
s.Named = &dnssec.NamedSigning{Secure: zs.Secure, InlineSigning: zs.InlineSigning, Serial: zs.Serial, SignedSerial: zs.SignedSerial, NextKeyEvent: zs.NextKeyEvent}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// dnssecChanges checks the DNSSEC side of going from base to work: the
|
||||
// steps that would make a signed zone fail to validate, or give it new
|
||||
// keys its parent's DS doesn't match.
|
||||
func (e *Engine) dnssecChanges(base, work *model.State, opts ApplyOptions) []validate.Problem {
|
||||
var out []validate.Problem
|
||||
problem := func(sev validate.Severity, st *model.State, z *model.Zone, code, format string, args ...any) {
|
||||
out = append(out, validate.Problem{Severity: sev, Code: code, Message: fmt.Sprintf(format, args...), ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: st.ViewName(z)})
|
||||
}
|
||||
for _, old := range base.SortedZones() {
|
||||
if !old.Signed() {
|
||||
continue
|
||||
}
|
||||
z := work.Zones[old.ID]
|
||||
if z == nil {
|
||||
problem(validate.Error, base, old, "dnssec_delete_signed", "the zone is signed: turn DNSSEC off before deleting it (jarl zone dnssec disable, and remove the DS record at your registrar), or the domain fails to validate while the parent still points at its keys")
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue // reported by validate (dnssec_zone_type)
|
||||
}
|
||||
if render.KeyDir(base, old, e.cfg.Paths) != render.KeyDir(work, z, e.cfg.Paths) {
|
||||
problem(validate.Error, work, z, "dnssec_keys_moved", "the zone is signed, and moving it to another view would make named sign it with new keys that the parent's DS record doesn't match; turn DNSSEC off first, or create the zone anew in the other view")
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case z.DNSSECPolicy == "" && opts.wipeZone == z.ID:
|
||||
// WipeDNSSEC: the caller checked that the parent has no DS.
|
||||
case z.DNSSECPolicy == "" && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Error, work, z, "dnssec_off_unsafe", "switching DNSSEC off at once makes named serve the zone unsigned immediately, and if the parent zone has a DS record for it, validating resolvers then reject the whole domain. Turn it off in steps: 'jarl zone dnssec disable' (the insecure policy) keeps the zone signed while you remove the DS at your registrar, and Jarl says when it's safe to finish. If the zone never had a DS at its parent, wipe it instead ('jarl zone dnssec wipe')")
|
||||
case z.DNSSECPolicy == "":
|
||||
keys, err := dnssec.Keys(render.KeyDir(base, old, e.cfg.Paths), old.Name)
|
||||
if err != nil {
|
||||
problem(validate.Error, work, z, "dnssec_off_unknown", "can't tell whether turning DNSSEC off has finished, because the zone's keys can't be read: %v", err)
|
||||
continue
|
||||
}
|
||||
if ph := dnssec.Assess(model.DNSSECInsecure, keys); !ph.CanFinish {
|
||||
problem(validate.Error, work, z, "dnssec_off_too_early", "turning DNSSEC off hasn't finished yet: %s %s", ph.Summary, ph.Next)
|
||||
}
|
||||
case z.DNSSECPolicy != old.DNSSECPolicy && z.DNSSECPolicy != model.DNSSECInsecure && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Warning, work, z, "dnssec_policy_change", "changing the zone's DNSSEC policy from %s to %s can make named replace its keys; when its key-signing key changes, the DS record at the parent must change too (Jarl shows when)", old.DNSSECPolicy, z.DNSSECPolicy)
|
||||
}
|
||||
}
|
||||
for _, z := range work.SortedZones() {
|
||||
if !z.Signed() || z.DNSSECPolicy == model.DNSSECInsecure {
|
||||
continue
|
||||
}
|
||||
if old := base.Zones[z.ID]; old != nil && old.Signed() {
|
||||
continue
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(work, z, e.cfg.Paths), z.Name); len(files) > 0 {
|
||||
problem(validate.Info, work, z, "dnssec_old_keys", "the zone's key directory still has keys from when it was signed before; named only reuses keys it hasn't retired, and makes new ones otherwise (wipe the old ones first for a clean start)")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// removeDerived deletes what named kept for zones that are gone after a
|
||||
// change: the journals and signed copies next to their files and, for a
|
||||
// deleted zone, keys left over from when it was signed (a signed zone
|
||||
// can't be deleted). Failures are only logged.
|
||||
func (e *Engine) removeDerived(pl *plan) {
|
||||
for _, path := range pl.removed {
|
||||
for _, f := range dnssec.DerivedFiles(path) {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a file named kept for a deleted zone failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, old := range pl.base.Zones {
|
||||
if old.Type != model.ZonePrimary || pl.work.Zones[id] != nil || pl.work.FindZone(old.Name, old.ViewID) != nil {
|
||||
continue
|
||||
}
|
||||
dir := render.KeyDir(pl.base, old, e.cfg.Paths)
|
||||
files, _ := dnssec.Files(dir, old.Name)
|
||||
for _, f := range files {
|
||||
if err := os.Remove(filepath.Join(dir, f)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a deleted zone's old DNSSEC key failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WipeResult is the outcome of WipeDNSSEC.
|
||||
type WipeResult struct {
|
||||
Result *Result `json:"result,omitempty"` // the change that switched signing off, if one was needed
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// WipeDNSSEC switches signing off for a zone at once, skipping the
|
||||
// insecure policy, and deletes its keys and named's signed copy of it, so
|
||||
// that signing it again starts from scratch. The caller must make sure the
|
||||
// parent zone has no DS record for the zone: otherwise the domain stops
|
||||
// validating.
|
||||
func (e *Engine) WipeDNSSEC(ctx context.Context, zoneID int64, p *auth.Principal, via string, opts ApplyOptions) (*WipeResult, error) {
|
||||
st, err := e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z := st.Zones[zoneID]
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %d not found", zoneID)
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones are signed by Jarl")
|
||||
}
|
||||
if !p.Can(zoneAction(st, z, true)) {
|
||||
return nil, errf(KindForbidden, "permission denied: this needs write access to zone %s", zoneDesc(st, z))
|
||||
}
|
||||
out := &WipeResult{Removed: []string{}, DryRun: opts.DryRun}
|
||||
dir := render.KeyDir(st, z, e.cfg.Paths)
|
||||
keyFiles, err := dnssec.Files(dir, z.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the key directory: %w", err)
|
||||
}
|
||||
zoneFile := render.ZoneFilePath(st, z, e.cfg.Paths)
|
||||
var signed []string
|
||||
for _, f := range dnssec.DerivedFiles(zoneFile) {
|
||||
if strings.Contains(filepath.Base(f), ".signed") {
|
||||
signed = append(signed, f)
|
||||
}
|
||||
}
|
||||
if z.DNSSECPolicy == "" && len(keyFiles) == 0 && len(signed) == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't signed and has no keys or signed files left: there is nothing to wipe", zoneDesc(st, z))
|
||||
}
|
||||
if z.DNSSECPolicy != "" {
|
||||
opts.wipeZone = z.ID
|
||||
res, err := e.Submit(ctx, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})}, p, via, "wipe DNSSEC from zone "+zoneDesc(st, z), opts)
|
||||
out.Result = res
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, f := range keyFiles {
|
||||
out.Removed = append(out.Removed, filepath.Join(dir, f))
|
||||
}
|
||||
out.Removed = append(out.Removed, signed...)
|
||||
if opts.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var failed []string
|
||||
for _, f := range out.Removed {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("%d files removed", len(out.Removed))
|
||||
if len(failed) > 0 {
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "failure", strings.Join(failed, "; "), nil)
|
||||
return out, fmt.Errorf("signing is off, but removing files failed: %s", strings.Join(failed, "; "))
|
||||
}
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "success", msg, nil)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkDS tells named that the parent zone now has (published) or no
|
||||
// longer has (withdrawn) the DS record of each key, as a DS check found.
|
||||
// named moves to the next step of signing or unsigning the zone once it
|
||||
// knows, and (unless it can query the parent itself) waits until told.
|
||||
func (e *Engine) MarkDS(ctx context.Context, st *model.State, z *model.Zone, p *auth.Principal, published, withdrawn []*dnssec.Key) ([]string, error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, errf(KindConflict, "jarld doesn't control named here (bind.no_reload)")
|
||||
}
|
||||
var out []string
|
||||
run := func(k *dnssec.Key, pub bool) error {
|
||||
msg, err := e.tools.CheckDS(ctx, z.Name, e.namedView(st, z), k.Tag, k.Algorithm, pub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, fmt.Sprintf("key %d: %s", k.Tag, strings.TrimSpace(msg)))
|
||||
return nil
|
||||
}
|
||||
for _, k := range published {
|
||||
if err := run(k, true); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, k := range withdrawn {
|
||||
if err := run(k, false); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
e.audit(ctx, p, "dnssec.checkds", "zone", z.ID, zoneDesc(st, z), 0, "success", strings.Join(out, "; "), nil)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// waitPhase polls the zone's DNSSEC status until ok accepts it.
|
||||
func (n *namedEnv) waitPhase(z *model.Zone, what string, ok func(*dnssec.Status) bool) *dnssec.Status {
|
||||
n.t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
st := n.state()
|
||||
s := n.e.DNSSEC(n.ctx, st, st.Zones[z.ID])
|
||||
if ok(s) {
|
||||
return s
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
n.t.Fatalf("waiting for %s: %+v (phase %+v)", what, s, s.Phase)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSECWithRealNamed(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
z := n.state().ZonesNamed("example.test")[0]
|
||||
zoneFile := filepath.Join(n.dir, "zones/_default/example.test.zone")
|
||||
|
||||
// Switching signing on for an existing zone: named makes a key and
|
||||
// signs the zone (inline signing, keeping Jarl's file as it is).
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
conf, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
|
||||
if !strings.Contains(string(conf), `dnssec-policy "default";`) || !strings.Contains(string(conf), `key-directory "`+filepath.Join(n.dir, "zones/dnssec-keys/_default")+`";`) {
|
||||
t.Errorf("named.conf:\n%s", conf)
|
||||
}
|
||||
s := n.waitPhase(z, "signing", func(s *dnssec.Status) bool { return len(s.Keys) == 1 && s.Named != nil && s.Named.Secure })
|
||||
k := s.Keys[0]
|
||||
if s.Phase.Code != dnssec.PhaseSigning || k.Role != "CSK" || k.Algorithm != 13 || k.DS == nil || s.Phase.DSReadyAt == nil {
|
||||
t.Errorf("status: %+v, key %+v", s.Phase, k)
|
||||
}
|
||||
if got := n.dig("example.test", "DNSKEY"); !strings.HasPrefix(got, "257 3 13 ") {
|
||||
t.Errorf("DNSKEY = %q", got)
|
||||
}
|
||||
// Changes still load (Jarl's serial is the unsigned zone's), and the
|
||||
// serial check sees the signed zone's serial.
|
||||
r := findRec(n.state(), "www.example.test.", "A")
|
||||
n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.12" {
|
||||
t.Errorf("www = %q", got)
|
||||
}
|
||||
cur := n.state()
|
||||
served, ok, err := n.e.LoadedSerial(n.ctx, cur, cur.Zones[z.ID])
|
||||
if !ok || err != nil || !serialGreater(served, cur.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("served serial %d (%v %v), Jarl's %d", served, ok, err, cur.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
|
||||
// Unsafe steps are refused.
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})); !strings.Contains(e.Message, "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off directly: %v", e)
|
||||
}
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneDelete, z.ID, nil)); !strings.Contains(e.Message, "turn DNSSEC off before deleting") {
|
||||
t.Errorf("deleting: %v", e)
|
||||
}
|
||||
r = findRec(n.state(), "www.example.test.", "A")
|
||||
if e := n.submitErr(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{TTL: model.Some(uint32(172800))})); !strings.Contains(e.Message, "max-zone-ttl") {
|
||||
t.Errorf("long TTL: %v", e)
|
||||
}
|
||||
|
||||
// A restore of an older database: Jarl's serial is now below named's,
|
||||
// which named refuses for a signed zone. Resync raises it.
|
||||
before := n.state()
|
||||
older := before.Clone()
|
||||
older.Zones[z.ID].SOA.Serial -= 5
|
||||
findRec(older, "www.example.test.", "A").Data = "192.0.2.10"
|
||||
if err := n.st.InTx(n.ctx, func(tx *store.Tx) error { return tx.SaveState(before, older) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := n.e.Resync(n.ctx, n.admin, ApplyOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("resync: %v", err)
|
||||
}
|
||||
if len(res.Serials) != 1 || !serialGreater(res.Serials[0].New, before.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("serials: %+v (named had %d)", res.Serials, before.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after restore = %q", got)
|
||||
}
|
||||
if n.state().Zones[z.ID].SOA.Serial != res.Serials[0].New {
|
||||
t.Error("raised serial not saved")
|
||||
}
|
||||
|
||||
// Turning off: insecure first. The DS was never at the parent, so
|
||||
// named removes the keys at once and finishing is allowed.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("insecure")}))
|
||||
n.waitPhase(z, "removal", func(s *dnssec.Status) bool { return s.Phase.CanFinish })
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")}))
|
||||
if got := n.dig("example.test", "DNSKEY"); got != "" {
|
||||
t.Errorf("DNSKEY after switching off = %q", got)
|
||||
}
|
||||
s = n.waitPhase(z, "leftover keys", func(s *dnssec.Status) bool { return true })
|
||||
if s.Phase.Code != dnssec.PhaseLeftover {
|
||||
t.Errorf("after switching off: %+v", s.Phase)
|
||||
}
|
||||
|
||||
// Signing again makes a new key (the old one stays retired); a wipe
|
||||
// switches off at once and deletes keys and signed files.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
s = n.waitPhase(z, "signing again", func(s *dnssec.Status) bool { return len(s.Keys) == 2 && s.Named != nil && s.Named.Secure })
|
||||
if s.Phase.Code != dnssec.PhaseSigning || s.Keys[0].Tag == k.Tag && s.Keys[0].Goal == dnssec.Omnipresent {
|
||||
t.Errorf("signing again: %+v %+v", s.Phase, s.Keys[0])
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); err != nil {
|
||||
t.Errorf("signed copy: %v", err)
|
||||
}
|
||||
w, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("wipe: %v", err)
|
||||
}
|
||||
if len(w.Removed) < 4 || w.Result == nil || !w.Result.Applied {
|
||||
t.Errorf("wipe: %+v", w)
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(n.state(), z, n.e.cfg.Paths), z.Name); len(files) != 0 {
|
||||
t.Errorf("keys left: %v", files)
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); !os.IsNotExist(err) {
|
||||
t.Errorf("signed copy left: %v", err)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after wipe = %q", got)
|
||||
}
|
||||
if _, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}); err == nil {
|
||||
t.Error("a second wipe found something")
|
||||
}
|
||||
// Deleting the zone also removes named's journal.
|
||||
n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if left, _ := filepath.Glob(zoneFile + "*"); len(left) != 0 {
|
||||
t.Errorf("left after deleting the zone: %v", left)
|
||||
}
|
||||
}
|
||||
+40
-11
@@ -122,6 +122,8 @@ type ApplyOptions struct {
|
||||
Force bool
|
||||
// NoReload writes the files without reloading named.
|
||||
NoReload bool
|
||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||
wipeZone int64
|
||||
}
|
||||
|
||||
func (e *Engine) replay(base *model.State, ops []model.Op, p *auth.Principal) (*model.State, []string, error) {
|
||||
@@ -397,11 +399,11 @@ func (pl *plan) files() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string) (*plan, error) {
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||
before := e.validate(base)
|
||||
after := e.validate(work)
|
||||
pl.introduced = validate.Introduced(before, after)
|
||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||
introducedKeys := map[string]bool{}
|
||||
for _, p := range pl.introduced {
|
||||
introducedKeys[p.Key()] = true
|
||||
@@ -415,6 +417,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
return pl, nil
|
||||
}
|
||||
pl.serials = bumpSerials(base, work, e.Now(), user)
|
||||
guarded, problems := e.guardSerials(ctx, base, work, pl.serials, user)
|
||||
pl.serials = append(pl.serials, guarded...)
|
||||
if validate.HasErrors(problems) {
|
||||
pl.introduced = append(pl.introduced, problems...)
|
||||
return pl, nil
|
||||
}
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
|
||||
deployed, err := e.store.DeployedFiles(ctx)
|
||||
if err != nil {
|
||||
@@ -498,6 +506,11 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
|
||||
defer os.RemoveAll(dir)
|
||||
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
||||
sout := render.Render(pl.work, sp, e.cfg.Extras)
|
||||
for _, d := range sout.Dirs {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, f := range sout.Files {
|
||||
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
||||
return err
|
||||
@@ -635,7 +648,7 @@ func (e *Engine) Preview(ctx context.Context, csID int64, p *auth.Principal) (*R
|
||||
res.Error = ee
|
||||
return res, nil
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -714,7 +727,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
cs.Description += fmt.Sprintf(" (+%d more)", len(cs.Ops)-1)
|
||||
}
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -782,6 +795,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
}
|
||||
res.ChangesetID = cs.ID
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
|
||||
return res, nil
|
||||
}
|
||||
@@ -956,15 +970,16 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
return nil, err
|
||||
}
|
||||
work := base.Clone()
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := pl.result()
|
||||
res.DryRun = opts.DryRun
|
||||
if validate.HasErrors(pl.existing) {
|
||||
if validate.HasErrors(pl.existing) || validate.HasErrors(pl.introduced) {
|
||||
// Resync deploys the stored state as is; it must be valid.
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(pl.existing), Problems: validate.Errors(pl.existing)}
|
||||
ps := append(append([]validate.Problem{}, pl.existing...), pl.introduced...)
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(ps), Problems: validate.Errors(ps)}
|
||||
}
|
||||
if pl.check != nil && !pl.check.OK {
|
||||
return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs}
|
||||
@@ -998,15 +1013,26 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
}
|
||||
deployed := deployedList(pl.out, 0, e.Now())
|
||||
err = e.store.InTx(ctx, func(tx *store.Tx) error {
|
||||
// Serials raised above named's (guardSerials) are kept.
|
||||
if len(pl.serials) > 0 {
|
||||
if err := tx.SaveState(base, work); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.SetDeployedFiles(deployed); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), nil))
|
||||
var details json.RawMessage
|
||||
if len(pl.serials) > 0 {
|
||||
details, _ = json.Marshal(map[string]any{"serials": pl.serials})
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), details))
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -1058,7 +1084,7 @@ func (e *Engine) CheckCurrent(ctx context.Context) (*Result, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "")
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "", ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1192,7 +1218,9 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
|
||||
}
|
||||
|
||||
// LoadedSerial returns the serial named serves for zone z, from rndc
|
||||
// zonestatus. ok is false when named isn't asked (no_reload) or doesn't
|
||||
// zonestatus: for a zone named signs, the signed zone's serial (which
|
||||
// secondaries and the rest of the world see), not that of the zone file
|
||||
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
|
||||
// report a serial (the zone isn't loaded yet).
|
||||
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
@@ -1202,5 +1230,6 @@ func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zon
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return zs.Serial, zs.HasSerial, nil
|
||||
serial, ok = zs.ServedSerial()
|
||||
return serial, ok, nil
|
||||
}
|
||||
@@ -87,7 +87,11 @@ type ZoneInput struct {
|
||||
SOA *SOAInput `json:"soa,omitempty"`
|
||||
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// DNSSECPolicy turns signing on ("default" or a custom policy's
|
||||
// name), starts turning it off ("insecure") or, once that has
|
||||
// finished, off ("").
|
||||
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
|
||||
@@ -230,6 +230,16 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
||||
if in.SerialPolicy != nil {
|
||||
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
||||
}
|
||||
if in.DNSSECPolicy != nil {
|
||||
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
||||
switch strings.ToLower(p) {
|
||||
case "none", "off":
|
||||
p = ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
z.DNSSECPolicy = p
|
||||
}
|
||||
if in.Comment != nil {
|
||||
z.Comment = *in.Comment
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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
|
||||
@@ -73,3 +76,59 @@ func bumpSerials(base, work *model.State, now time.Time, user string) []SerialCh
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user