Files
jarl/internal/engine/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

240 lines
10 KiB
Go

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
}