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

+40 -11
View File
@@ -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
}