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 }